diff --git a/CLA.md b/CLA.md new file mode 100644 index 000000000000..a87035b2d839 --- /dev/null +++ b/CLA.md @@ -0,0 +1,20 @@ +## Expensify Individual Contributor License Agreement + +Thank you for your interest in contributing to open source software projects (“Projects”) made available by Expensify Inc or its affiliates (“Expensify”). This Individual Contributor License Agreement (“Agreement”) sets out the terms governing any source code, object code, bug fixes, configuration changes, tools, specifications, documentation, data, materials, feedback, information or other works of authorship that you submit or have submitted, in any form and in any manner, to Expensify in respect of any of the Projects (collectively “Contributions”). If you have any questions respecting this Agreement, please contact reactnative@expensify.com. + +You agree that the following terms apply to all of your past, present and future Contributions. Except for the licenses granted in this Agreement, you retain all of your rights, title and interest in and to your Contributions. + +**Copyright License.** You hereby grant, and agree to grant, to Expensify a non-exclusive, perpetual, irrevocable, worldwide, fully-paid, royalty-free, transferable copyright license to reproduce, prepare derivative works of, publicly display, publicly perform, and distribute your Contributions and such derivative works, with the right to sublicense the foregoing rights through multiple tiers of sublicensees. + +**Patent License.** You hereby grant, and agree to grant, to Expensify a non-exclusive, perpetual, irrevocable, worldwide, fully-paid, royalty-free, transferable patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer your Contributions, where such license applies only to those patent claims licensable by you that are necessarily infringed by your Contributions alone or by combination of your Contributions with the Project to which such Contributions were submitted, with the right to sublicense the foregoing rights through multiple tiers of sublicensees. + +**Moral Rights.** To the fullest extent permitted under applicable law, you hereby waive, and agree not to assert, all of your “moral rights” in or relating to your Contributions for the benefit of Expensify, its assigns, and their respective direct and indirect sublicensees. + +**Third Party Content/Rights.** If your Contribution includes or is based on any source code, object code, bug fixes, configuration changes, tools, specifications, documentation, data, materials, feedback, information or other works of authorship that were not authored by you (“Third Party Content”) or if you are aware of any third party intellectual property or proprietary rights associated with your Contribution (“Third Party Rights”), then you agree to include with the submission of your Contribution full details respecting such Third Party Content and Third Party Rights, including, without limitation, identification of which aspects of your Contribution contain Third Party Content or are associated with Third Party Rights, the owner/author of the Third Party Content and Third Party Rights, where you obtained the Third Party Content, and any applicable third party license terms or restrictions respecting the Third Party Content and Third Party Rights. For greater certainty, the foregoing obligations respecting the identification of Third Party Content and Third Party Rights do not apply to any portion of a Project that is incorporated into your Contribution to that same Project. + +**Representations.** You represent that, other than the Third Party Content and Third Party Rights identified by you in accordance with this Agreement, you are the sole author of your Contributions and are legally entitled to grant the foregoing licenses and waivers in respect of your Contributions. If your Contributions were created in the course of your employment with your past or present employer(s), you represent that such employer(s) has authorized you to make your Contributions on behalf of such employer(s) or such employer(s) has waived all of their right, title or interest in or to your Contributions. + +**No Obligation.** You acknowledge that Expensify is under no obligation to use or incorporate your Contributions into any of the Projects. The decision to use or incorporate your Contributions into any of the Projects will be made at the sole discretion of Expensify or its authorized delegates. + +**Assignment.** You agree that Expensify may assign this Agreement, and all of its rights, obligations and licenses hereunder. + diff --git a/android/app/build.gradle b/android/app/build.gradle index ad6493f7c3ee..18aa3af18dbb 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 1009007201 - versionName "9.0.72-1" + versionCode 1009007300 + versionName "9.0.73-0" // 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/blob/main/CLA.md b/blob/main/CLA.md new file mode 100644 index 000000000000..a87035b2d839 --- /dev/null +++ b/blob/main/CLA.md @@ -0,0 +1,20 @@ +## Expensify Individual Contributor License Agreement + +Thank you for your interest in contributing to open source software projects (“Projects”) made available by Expensify Inc or its affiliates (“Expensify”). This Individual Contributor License Agreement (“Agreement”) sets out the terms governing any source code, object code, bug fixes, configuration changes, tools, specifications, documentation, data, materials, feedback, information or other works of authorship that you submit or have submitted, in any form and in any manner, to Expensify in respect of any of the Projects (collectively “Contributions”). If you have any questions respecting this Agreement, please contact reactnative@expensify.com. + +You agree that the following terms apply to all of your past, present and future Contributions. Except for the licenses granted in this Agreement, you retain all of your rights, title and interest in and to your Contributions. + +**Copyright License.** You hereby grant, and agree to grant, to Expensify a non-exclusive, perpetual, irrevocable, worldwide, fully-paid, royalty-free, transferable copyright license to reproduce, prepare derivative works of, publicly display, publicly perform, and distribute your Contributions and such derivative works, with the right to sublicense the foregoing rights through multiple tiers of sublicensees. + +**Patent License.** You hereby grant, and agree to grant, to Expensify a non-exclusive, perpetual, irrevocable, worldwide, fully-paid, royalty-free, transferable patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer your Contributions, where such license applies only to those patent claims licensable by you that are necessarily infringed by your Contributions alone or by combination of your Contributions with the Project to which such Contributions were submitted, with the right to sublicense the foregoing rights through multiple tiers of sublicensees. + +**Moral Rights.** To the fullest extent permitted under applicable law, you hereby waive, and agree not to assert, all of your “moral rights” in or relating to your Contributions for the benefit of Expensify, its assigns, and their respective direct and indirect sublicensees. + +**Third Party Content/Rights.** If your Contribution includes or is based on any source code, object code, bug fixes, configuration changes, tools, specifications, documentation, data, materials, feedback, information or other works of authorship that were not authored by you (“Third Party Content”) or if you are aware of any third party intellectual property or proprietary rights associated with your Contribution (“Third Party Rights”), then you agree to include with the submission of your Contribution full details respecting such Third Party Content and Third Party Rights, including, without limitation, identification of which aspects of your Contribution contain Third Party Content or are associated with Third Party Rights, the owner/author of the Third Party Content and Third Party Rights, where you obtained the Third Party Content, and any applicable third party license terms or restrictions respecting the Third Party Content and Third Party Rights. For greater certainty, the foregoing obligations respecting the identification of Third Party Content and Third Party Rights do not apply to any portion of a Project that is incorporated into your Contribution to that same Project. + +**Representations.** You represent that, other than the Third Party Content and Third Party Rights identified by you in accordance with this Agreement, you are the sole author of your Contributions and are legally entitled to grant the foregoing licenses and waivers in respect of your Contributions. If your Contributions were created in the course of your employment with your past or present employer(s), you represent that such employer(s) has authorized you to make your Contributions on behalf of such employer(s) or such employer(s) has waived all of their right, title or interest in or to your Contributions. + +**No Obligation.** You acknowledge that Expensify is under no obligation to use or incorporate your Contributions into any of the Projects. The decision to use or incorporate your Contributions into any of the Projects will be made at the sole discretion of Expensify or its authorized delegates. + +**Assignment.** You agree that Expensify may assign this Agreement, and all of its rights, obligations and licenses hereunder. + diff --git a/cla.json b/cla.json new file mode 100644 index 000000000000..425e3ac1769c --- /dev/null +++ b/cla.json @@ -0,0 +1,17 @@ +{ + "signatories": [ + { + "name": "Saif Ur Rehman", + "github_username": "saifelance", + "email": "saifelance@gmail.com", + "signed_at": "2024-12-08" + }, + { + "name": "Saif Ur Rehman", + "github_username": "saifelance", + "email": "saifelance@gmail.com", + "signed_at": "2024-12-09" + } + ] + } + \ No newline at end of file diff --git a/ios/NewExpensify/Info.plist b/ios/NewExpensify/Info.plist index 9d846dfbe5f6..c020062743e4 100644 --- a/ios/NewExpensify/Info.plist +++ b/ios/NewExpensify/Info.plist @@ -19,7 +19,7 @@ CFBundlePackageType APPL CFBundleShortVersionString - 9.0.72 + 9.0.73 CFBundleSignature ???? CFBundleURLTypes @@ -40,7 +40,7 @@ CFBundleVersion - 9.0.72.1 + 9.0.73.0 FullStory OrgId diff --git a/ios/NewExpensifyTests/Info.plist b/ios/NewExpensifyTests/Info.plist index f20e45286bed..c261d9725505 100644 --- a/ios/NewExpensifyTests/Info.plist +++ b/ios/NewExpensifyTests/Info.plist @@ -15,10 +15,10 @@ CFBundlePackageType BNDL CFBundleShortVersionString - 9.0.72 + 9.0.73 CFBundleSignature ???? CFBundleVersion - 9.0.72.1 + 9.0.73.0 diff --git a/ios/NotificationServiceExtension/Info.plist b/ios/NotificationServiceExtension/Info.plist index e04c76769d99..2a76f86f0fa6 100644 --- a/ios/NotificationServiceExtension/Info.plist +++ b/ios/NotificationServiceExtension/Info.plist @@ -11,9 +11,9 @@ CFBundleName $(PRODUCT_NAME) CFBundleShortVersionString - 9.0.72 + 9.0.73 CFBundleVersion - 9.0.72.1 + 9.0.73.0 NSExtension NSExtensionPointIdentifier diff --git a/lib/react-compiler-runtime/index.js b/lib/react-compiler-runtime/index.js new file mode 100644 index 000000000000..54e88d2b703a --- /dev/null +++ b/lib/react-compiler-runtime/index.js @@ -0,0 +1,21 @@ +// lib/react-compiler-runtime.js +const $empty = Symbol.for("react.memo_cache_sentinel"); +const React = require('react'); +/** + * DANGER: this hook is NEVER meant to be called directly! + * + * Note that this is a temporary userspace implementation of this function + * from React 19. It is not as efficient and may invalidate more frequently + * than the official API. Better to upgrade to React 19 as soon as we can. + **/ +export function c(size) { + return React.useState(() => { + const $ = new Array(size); + for (let ii = 0; ii < size; ii++) { + $[ii] = $empty; + } + // @ts-ignore + $[$empty] = true; + return $; + })[0]; +} diff --git a/lib/react-compiler-runtime/package.json b/lib/react-compiler-runtime/package.json new file mode 100644 index 000000000000..86de67e130ce --- /dev/null +++ b/lib/react-compiler-runtime/package.json @@ -0,0 +1,10 @@ +{ + "name": "react-compiler-runtime", + "version": "0.0.1", + "description": "Runtime for React Compiler", + "license": "MIT", + "main": "index.js", + "dependencies": { + "react": "^18.2.0" + } +} diff --git a/package-lock.json b/package-lock.json index b8bd65a36d9a..d97525d478af 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "new.expensify", - "version": "9.0.72-1", + "version": "9.0.73-0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "new.expensify", - "version": "9.0.72-1", + "version": "9.0.73-0", "hasInstallScript": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index de12b8cb0619..b50291a3fa57 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "new.expensify", - "version": "9.0.72-1", + "version": "9.0.73-0", "author": "Expensify, Inc.", "homepage": "https://new.expensify.com", "description": "New Expensify is the next generation of Expensify: a reimagination of payments based atop a foundation of chat.", diff --git a/src/components/MoneyReportHeader.tsx b/src/components/MoneyReportHeader.tsx index 6f47ad19239f..36310e4eb588 100644 --- a/src/components/MoneyReportHeader.tsx +++ b/src/components/MoneyReportHeader.tsx @@ -111,6 +111,7 @@ function MoneyReportHeader({policy, report: moneyRequestReport, transactionThrea const isDraft = ReportUtils.isOpenExpenseReport(moneyRequestReport); const connectedIntegration = PolicyUtils.getConnectedIntegration(policy); const navigateBackToAfterDelete = useRef(); + const hasHeldExpenses = ReportUtils.hasHeldExpenses(moneyRequestReport?.reportID); const hasScanningReceipt = ReportUtils.getTransactionsWithReceipts(moneyRequestReport?.reportID).some((t) => TransactionUtils.isReceiptBeingScanned(t)); const hasOnlyPendingTransactions = allTransactions.length > 0 && allTransactions.every((t) => TransactionUtils.isExpensifyCardTransaction(t) && TransactionUtils.isPending(t)); const transactionIDs = allTransactions.map((t) => t.transactionID); @@ -361,6 +362,7 @@ function MoneyReportHeader({policy, report: moneyRequestReport, transactionThrea {shouldShowSettlementButton && !shouldUseNarrowLayout && ( (); - const [paymentType, setPaymentType] = useState(); const getCanIOUBePaid = useCallback( @@ -140,6 +139,7 @@ function ReportPreview({ const {nonHeldAmount, fullAmount, hasValidNonHeldAmount} = ReportUtils.getNonHeldAndFullAmount(iouReport, shouldShowPayButton); const hasOnlyHeldExpenses = ReportUtils.hasOnlyHeldExpenses(iouReport?.reportID ?? ''); + const hasHeldExpenses = ReportUtils.hasHeldExpenses(iouReport?.reportID ?? ''); const managerID = iouReport?.managerID ?? action.childManagerAccountID ?? 0; const {totalDisplaySpend, reimbursableSpend} = ReportUtils.getMoneyRequestSpendBreakdown(iouReport); @@ -534,6 +534,7 @@ function ReportPreview({ {shouldShowSettlementButton && ( = type ActionCellProps = { action?: SearchTransactionAction; + shouldUseSuccessStyle?: boolean; isLargeScreenWidth?: boolean; isSelected?: boolean; goToItem: () => void; @@ -35,6 +36,7 @@ type ActionCellProps = { function ActionCell({ action = CONST.SEARCH.ACTION_TYPES.VIEW, + shouldUseSuccessStyle = true, isLargeScreenWidth = true, isSelected = false, goToItem, @@ -99,7 +101,7 @@ function ActionCell({ style={[styles.w100]} innerStyles={buttonInnerStyles} isLoading={isLoading} - success + success={shouldUseSuccessStyle} isDisabled={isOffline} /> ); diff --git a/src/components/SelectionList/Search/ExpenseItemHeaderNarrow.tsx b/src/components/SelectionList/Search/ExpenseItemHeaderNarrow.tsx index 384262a78b15..096429cc41d5 100644 --- a/src/components/SelectionList/Search/ExpenseItemHeaderNarrow.tsx +++ b/src/components/SelectionList/Search/ExpenseItemHeaderNarrow.tsx @@ -24,6 +24,7 @@ type ExpenseItemHeaderNarrowProps = { containerStyle?: StyleProp; onButtonPress: () => void; canSelectMultiple?: boolean; + shouldUseSuccessStyle?: boolean; isSelected?: boolean; isDisabled?: boolean | null; isDisabledCheckbox?: boolean; @@ -41,6 +42,7 @@ function ExpenseItemHeaderNarrow({ canSelectMultiple, containerStyle, isDisabledCheckbox, + shouldUseSuccessStyle = true, isSelected, isDisabled, handleCheckboxPress, @@ -101,6 +103,7 @@ function ExpenseItemHeaderNarrow({ ({ return null; } + const hasHeldExpenses = ReportUtils.hasHeldExpenses('', reportItem.transactions); + const participantFrom = reportItem.from; const participantTo = reportItem.to; @@ -202,6 +205,7 @@ function ReportListItem({ TransactionUtils.isOnHold(item), [item]); + if (!isLargeScreenWidth) { return ( @@ -278,6 +280,7 @@ function TransactionListItemRow({ onButtonPress={onButtonPress} canSelectMultiple={canSelectMultiple} action={item.action} + shouldUseSuccessStyle={!isOnHold} isSelected={item.isSelected} isDisabled={item.isDisabled} isDisabledCheckbox={item.isDisabledCheckbox} @@ -444,6 +447,7 @@ function TransactionListItemRow({ {(triggerKYCFlow, buttonRef) => ( + success={shouldUseSuccessStyle} onOptionsMenuShow={onPaymentOptionsShow} onOptionsMenuHide={onPaymentOptionsHide} buttonRef={buttonRef} diff --git a/src/components/SettlementButton/types.ts b/src/components/SettlementButton/types.ts index df8fdedc512e..c9fd548c87bf 100644 --- a/src/components/SettlementButton/types.ts +++ b/src/components/SettlementButton/types.ts @@ -34,6 +34,9 @@ type SettlementButtonProps = { /** The IOU/Expense report we are paying */ iouReport?: OnyxEntry; + /** Whether to use the success style or not */ + shouldUseSuccessStyle?: boolean; + /** Should we show the payment options? */ shouldHidePaymentOptions?: boolean; diff --git a/src/components/WorkspaceSwitcherButton.tsx b/src/components/WorkspaceSwitcherButton.tsx index d47f243af736..04349526aaea 100644 --- a/src/components/WorkspaceSwitcherButton.tsx +++ b/src/components/WorkspaceSwitcherButton.tsx @@ -52,6 +52,7 @@ function WorkspaceSwitcherButton({policy, onSwitchWorkspace}: WorkspaceSwitcherB accessibilityRole={CONST.ROLE.BUTTON} accessibilityLabel={translate('common.workspaces')} accessible + testID="WorkspaceSwitcherButton" onPress={() => { onSwitchWorkspace?.(); pressableRef?.current?.blur(); diff --git a/src/languages/es.ts b/src/languages/es.ts index 2be618953135..66940ef87c6f 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -262,7 +262,7 @@ const translations = { phone: 'Teléfono', phoneNumber: 'Número de teléfono', phoneNumberPlaceholder: '(xxx) xxx-xxxx', - email: 'Email', + email: 'Correo electrónico', and: 'y', or: 'o', details: 'Detalles', @@ -349,7 +349,7 @@ const translations = { please: 'Por favor', rename: 'Renombrar', contactUs: 'contáctenos', - pleaseEnterEmailOrPhoneNumber: 'Por favor, escribe un email o número de teléfono', + pleaseEnterEmailOrPhoneNumber: 'Por favor, escribe un correo electrónico o número de teléfono', fixTheErrors: 'corrige los errores', inTheFormBeforeContinuing: 'en el formulario antes de continuar', confirm: 'Confirmar', @@ -562,7 +562,7 @@ const translations = { whatsItFor: '¿Para qué es?', }, selectionList: { - nameEmailOrPhoneNumber: 'Nombre, email o número de teléfono', + nameEmailOrPhoneNumber: 'Nombre, correo electrónico o número de teléfono', findMember: 'Encuentra un miembro', }, emptyList: { @@ -647,7 +647,7 @@ const translations = { copied: '¡Copiado!', copyLink: 'Copiar enlace', copyURLToClipboard: 'Copiar URL al portapapeles', - copyEmailToClipboard: 'Copiar email al portapapeles', + copyEmailToClipboard: 'Copiar correo electrónico al portapapeles', markAsUnread: 'Marcar como no leído', markAsRead: 'Marcar como leído', editAction: ({action}: EditActionParams) => `Editar ${action?.actionName === CONST.REPORT.ACTIONS.TYPE.IOU ? 'gasto' : 'comentario'}`, @@ -1075,7 +1075,7 @@ const translations = { }, loginField: { numberHasNotBeenValidated: 'El número no está validado todavía. Haz click en el botón para reenviar el enlace de confirmación via SMS.', - emailHasNotBeenValidated: 'El email no está validado todavía. Haz click en el botón para reenviar el enlace de confirmación via email.', + emailHasNotBeenValidated: 'El correo electrónico no está validado todavía. Haz click en el botón para reenviar el enlace de confirmación via correo electrónico.', }, avatarWithImagePicker: { uploadPhoto: 'Subir foto', @@ -1094,7 +1094,7 @@ const translations = { preferredPronouns: 'Pronombres preferidos', selectYourPronouns: 'Selecciona tus pronombres', selfSelectYourPronoun: 'Auto-selecciona tu pronombre', - emailAddress: 'Dirección de email', + emailAddress: 'Dirección de correo electrónico', setMyTimezoneAutomatically: 'Configura mi zona horaria automáticamente', timezone: 'Zona horaria', invalidFileMessage: 'Archivo inválido. Pruebe con una imagen diferente.', @@ -1730,7 +1730,7 @@ const translations = { incorrectPassword: 'Contraseña incorrecta. Por favor, inténtalo de nuevo.', incorrectLoginOrPassword: 'Usuario o contraseña incorrectos. Por favor, inténtalo de nuevo.', incorrect2fa: 'Código de autenticación de dos factores incorrecto. Por favor, inténtalo de nuevo.', - twoFactorAuthenticationEnabled: 'Tienes autenticación de 2 factores activada en esta cuenta. Por favor, conéctate usando tu email o número de teléfono.', + twoFactorAuthenticationEnabled: 'Tienes autenticación de 2 factores activada en esta cuenta. Por favor, conéctate usando tu correo electrónico o número de teléfono.', invalidLoginOrPassword: 'Usuario o clave incorrectos. Por favor, inténtalo de nuevo o restablece la contraseña.', unableToResetPassword: 'No se pudo cambiar tu clave. Probablemente porque el enlace para restablecer la contrasenña ha expirado. Te hemos enviado un nuevo enlace. Comprueba tu bandeja de entrada y carpeta de Spam.', @@ -1740,9 +1740,9 @@ const translations = { }, }, loginForm: { - phoneOrEmail: 'Número de teléfono o email', + phoneOrEmail: 'Número de teléfono o correo electrónico', error: { - invalidFormatEmailLogin: 'El email introducido no es válido. Corrígelo e inténtalo de nuevo.', + invalidFormatEmailLogin: 'El correo electrónico introducido no es válido. Corrígelo e inténtalo de nuevo.', }, cannotGetAccountDetails: 'No se pudieron cargar los detalles de tu cuenta. Por favor, intenta iniciar sesión de nuevo.', loginForm: 'Formulario de inicio de sesión', @@ -2021,7 +2021,7 @@ const translations = { }, messages: { errorMessageInvalidPhone: `Por favor, introduce un número de teléfono válido sin paréntesis o guiones. Si reside fuera de Estados Unidos, por favor incluye el prefijo internacional (p. ej. ${CONST.EXAMPLE_PHONE_NUMBER}).`, - errorMessageInvalidEmail: 'Email inválido', + errorMessageInvalidEmail: 'Correo electrónico inválido', userIsAlreadyMember: ({login, name}: UserIsAlreadyMemberParams) => `${login} ya es miembro de ${name}`, }, onfidoStep: { @@ -5372,7 +5372,7 @@ const translations = { enterPhoneEmail: 'Ingrese un correo electrónico o número de teléfono válido.', enterEmail: 'Introduce un correo electrónico.', enterValidEmail: 'Introduzca un correo electrónico válido.', - tryDifferentEmail: 'Por favor intenta con un e-mail diferente.', + tryDifferentEmail: 'Por favor intenta con un correo electrónico diferente.', }, }, cardTransactions: { @@ -5458,7 +5458,7 @@ const translations = { phrase2: ' para obtener ayuda', }, default: { - phrase1: 'Envía un email a ', + phrase1: 'Envía un correo electrónico a ', phrase2: ' para obtener ayuda con la configuración', }, }, diff --git a/src/libs/Navigation/AppNavigator/AuthScreens.tsx b/src/libs/Navigation/AppNavigator/AuthScreens.tsx index be3139e0d65b..7d897d485a78 100644 --- a/src/libs/Navigation/AppNavigator/AuthScreens.tsx +++ b/src/libs/Navigation/AppNavigator/AuthScreens.tsx @@ -241,7 +241,6 @@ function AuthScreens({session, lastOpenedPublicRoomID, initialLastUpdateIDApplie const {toggleSearch} = useSearchRouterContext(); const modal = useRef({}); - const [didPusherInit, setDidPusherInit] = useState(false); const {isOnboardingCompleted} = useOnboardingFlowRouter(); const [initialReportID] = useState(() => { const currentURL = getCurrentUrl(); @@ -280,9 +279,7 @@ function AuthScreens({session, lastOpenedPublicRoomID, initialLastUpdateIDApplie NetworkConnection.listenForReconnect(); NetworkConnection.onReconnect(handleNetworkReconnect); PusherConnectionManager.init(); - initializePusher().then(() => { - setDidPusherInit(true); - }); + initializePusher(); // In Hybrid App we decide to call one of those method when booting ND and we don't want to duplicate calls if (!NativeModules.HybridAppModule) { @@ -591,7 +588,7 @@ function AuthScreens({session, lastOpenedPublicRoomID, initialLastUpdateIDApplie - {didPusherInit && } + ); } diff --git a/src/libs/Pusher/pusher.ts b/src/libs/Pusher/pusher.ts index 1a511eec391c..c5bda63b8dba 100644 --- a/src/libs/Pusher/pusher.ts +++ b/src/libs/Pusher/pusher.ts @@ -74,6 +74,8 @@ let pusherSocketID = ''; const socketEventCallbacks: SocketEventCallback[] = []; let customAuthorizer: ChannelAuthorizerGenerator; +let initPromise: Promise; + const eventsBoundToChannels = new Map>(); /** @@ -88,7 +90,7 @@ function callSocketEventCallbacks(eventName: SocketEventName, data?: EventCallba * @returns resolves when Pusher has connected */ function init(args: Args, params?: unknown): Promise { - return new Promise((resolve) => { + initPromise = new Promise((resolve) => { if (socket) { resolve(); return; @@ -140,6 +142,8 @@ function init(args: Args, params?: unknown): Promise { callSocketEventCallbacks('state_change', states); }); }); + + return initPromise; } /** @@ -236,52 +240,55 @@ function subscribe( eventCallback: (data: EventData) => void = () => {}, onResubscribe = () => {}, ): Promise { - return new Promise((resolve, reject) => { - InteractionManager.runAfterInteractions(() => { - // We cannot call subscribe() before init(). Prevent any attempt to do this on dev. - if (!socket) { - throw new Error(`[Pusher] instance not found. Pusher.subscribe() + return initPromise.then( + () => + new Promise((resolve, reject) => { + InteractionManager.runAfterInteractions(() => { + // We cannot call subscribe() before init(). Prevent any attempt to do this on dev. + if (!socket) { + throw new Error(`[Pusher] instance not found. Pusher.subscribe() most likely has been called before Pusher.init()`); - } - - Log.info('[Pusher] Attempting to subscribe to channel', false, {channelName, eventName}); - let channel = getChannel(channelName); + } - if (!channel?.subscribed) { - channel = socket.subscribe(channelName); - let isBound = false; - channel.bind('pusher:subscription_succeeded', () => { - // Check so that we do not bind another event with each reconnect attempt - if (!isBound) { + Log.info('[Pusher] Attempting to subscribe to channel', false, {channelName, eventName}); + let channel = getChannel(channelName); + + if (!channel?.subscribed) { + channel = socket.subscribe(channelName); + let isBound = false; + channel.bind('pusher:subscription_succeeded', () => { + // Check so that we do not bind another event with each reconnect attempt + if (!isBound) { + bindEventToChannel(channel, eventName, eventCallback); + resolve(); + isBound = true; + return; + } + + // When subscribing for the first time we register a success callback that can be + // called multiple times when the subscription succeeds again in the future + // e.g. as a result of Pusher disconnecting and reconnecting. This callback does + // not fire on the first subscription_succeeded event. + onResubscribe(); + }); + + channel.bind('pusher:subscription_error', (data: PusherSubscribtionErrorData = {}) => { + const {type, error, status} = data; + Log.hmmm('[Pusher] Issue authenticating with Pusher during subscribe attempt.', { + channelName, + status, + type, + error, + }); + reject(error); + }); + } else { bindEventToChannel(channel, eventName, eventCallback); resolve(); - isBound = true; - return; } - - // When subscribing for the first time we register a success callback that can be - // called multiple times when the subscription succeeds again in the future - // e.g. as a result of Pusher disconnecting and reconnecting. This callback does - // not fire on the first subscription_succeeded event. - onResubscribe(); }); - - channel.bind('pusher:subscription_error', (data: PusherSubscribtionErrorData = {}) => { - const {type, error, status} = data; - Log.hmmm('[Pusher] Issue authenticating with Pusher during subscribe attempt.', { - channelName, - status, - type, - error, - }); - reject(error); - }); - } else { - bindEventToChannel(channel, eventName, eventCallback); - resolve(); - } - }); - }); + }), + ); } /** diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index ba507ef681c7..9fff3ade23f9 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -1805,7 +1805,7 @@ function canAddOrDeleteTransactions(moneyRequestReport: OnyxEntry): bool return isAwaitingFirstLevelApproval(moneyRequestReport); } - if (isReportApproved(moneyRequestReport) || isSettled(moneyRequestReport?.reportID)) { + if (isReportApproved(moneyRequestReport) || isClosedReport(moneyRequestReport) || isSettled(moneyRequestReport?.reportID)) { return false; } diff --git a/src/libs/actions/IOU.ts b/src/libs/actions/IOU.ts index 943a8883ac1f..4dd8dead69c8 100644 --- a/src/libs/actions/IOU.ts +++ b/src/libs/actions/IOU.ts @@ -217,6 +217,40 @@ type MoneyRequestInformationParams = { existingTransaction?: OnyxEntry; }; +type MoneyRequestOptimisticParams = { + chat: { + report: OnyxTypes.OnyxInputOrEntry; + createdAction: OptimisticCreatedReportAction; + reportPreviewAction: ReportAction; + }; + iou: { + report: OnyxTypes.Report; + createdAction: OptimisticCreatedReportAction; + action: OptimisticIOUReportAction; + }; + transactionParams: { + transaction: OnyxTypes.Transaction; + transactionThreadReport: OptimisticChatReport | null; + transactionThreadCreatedReportAction: OptimisticCreatedReportAction | null; + }; + policyRecentlyUsed: { + categories?: string[]; + tags?: OnyxTypes.RecentlyUsedTags; + currencies?: string[]; + }; + personalDetailListAction?: OnyxTypes.PersonalDetailsList; + nextStep?: OnyxTypes.ReportNextStep | null; +}; + +type BuildOnyxDataForMoneyRequestParams = { + isNewChatReport: boolean; + shouldCreateNewMoneyRequestReport: boolean; + isOneOnOneSplit?: boolean; + existingTransactionThreadReportID?: string; + policyParams?: RequestMoneyPolicyParams; + optimisticParams: MoneyRequestOptimisticParams; +}; + let allPersonalDetails: OnyxTypes.PersonalDetailsList = {}; Onyx.connect({ key: ONYXKEYS.PERSONAL_DETAILS_LIST, @@ -569,31 +603,20 @@ function getFieldViolationsOnyxData(iouReport: OnyxTypes.Report): SetRequired, - iouReport: OnyxTypes.Report, - transaction: OnyxTypes.Transaction, - chatCreatedAction: OptimisticCreatedReportAction, - iouCreatedAction: OptimisticCreatedReportAction, - iouAction: OptimisticIOUReportAction, - optimisticPersonalDetailListAction: OnyxTypes.PersonalDetailsList, - reportPreviewAction: ReportAction, - optimisticPolicyRecentlyUsedCategories: string[], - optimisticPolicyRecentlyUsedTags: OnyxTypes.RecentlyUsedTags, - isNewChatReport: boolean, - transactionThreadReport: OptimisticChatReport | null, - transactionThreadCreatedReportAction: OptimisticCreatedReportAction | null, - shouldCreateNewMoneyRequestReport: boolean, - policy?: OnyxTypes.OnyxInputOrEntry, - policyTagList?: OnyxTypes.OnyxInputOrEntry, - policyCategories?: OnyxTypes.OnyxInputOrEntry, - optimisticNextStep?: OnyxTypes.ReportNextStep | null, - isOneOnOneSplit = false, - existingTransactionThreadReportID?: string, - optimisticRecentlyUsedCurrencies?: string[], -): [OnyxUpdate[], OnyxUpdate[], OnyxUpdate[]] { +function buildOnyxDataForMoneyRequest(moneyRequestParams: BuildOnyxDataForMoneyRequestParams): [OnyxUpdate[], OnyxUpdate[], OnyxUpdate[]] { + const {isNewChatReport, shouldCreateNewMoneyRequestReport, isOneOnOneSplit = false, existingTransactionThreadReportID, policyParams = {}, optimisticParams} = moneyRequestParams; + const {policy, policyCategories, policyTagList} = policyParams; + const { + chat, + iou, + transactionParams: {transaction, transactionThreadReport, transactionThreadCreatedReportAction}, + policyRecentlyUsed, + personalDetailListAction, + nextStep, + } = optimisticParams; + const isScanRequest = TransactionUtils.isScanRequest(transaction); - const outstandingChildRequest = ReportUtils.getOutstandingChildRequest(iouReport); + const outstandingChildRequest = ReportUtils.getOutstandingChildRequest(iou.report); const clearedPendingFields = Object.fromEntries(Object.keys(transaction.pendingFields ?? {}).map((key) => [key, null])); const optimisticData: OnyxUpdate[] = []; const successData: OnyxUpdate[] = []; @@ -603,16 +626,16 @@ function buildOnyxDataForMoneyRequest( } const existingTransactionThreadReport = ReportConnection.getAllReports()?.[`${ONYXKEYS.COLLECTION.REPORT}${existingTransactionThreadReportID}`] ?? null; - if (chatReport) { + if (chat.report) { optimisticData.push({ // Use SET for new reports because it doesn't exist yet, is faster and we need the data to be available when we navigate to the chat page onyxMethod: isNewChatReport ? Onyx.METHOD.SET : Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT}${chatReport.reportID}`, + key: `${ONYXKEYS.COLLECTION.REPORT}${chat.report.reportID}`, value: { - ...chatReport, + ...chat.report, lastReadTime: DateUtils.getDBTime(), lastMessageTranslationKey: '', - iouReportID: iouReport.reportID, + iouReportID: iou.report.reportID, ...outstandingChildRequest, ...(isNewChatReport ? {pendingFields: {createChat: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD}} : {}), }, @@ -622,12 +645,12 @@ function buildOnyxDataForMoneyRequest( optimisticData.push( { onyxMethod: shouldCreateNewMoneyRequestReport ? Onyx.METHOD.SET : Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT}${iouReport.reportID}`, + key: `${ONYXKEYS.COLLECTION.REPORT}${iou.report.reportID}`, value: { - ...iouReport, - lastMessageText: ReportActionsUtils.getReportActionText(iouAction), - lastMessageHtml: ReportActionsUtils.getReportActionHtml(iouAction), - lastVisibleActionCreated: iouAction.created, + ...iou.report, + lastMessageText: ReportActionsUtils.getReportActionText(iou.action), + lastMessageHtml: ReportActionsUtils.getReportActionHtml(iou.action), + lastVisibleActionCreated: iou.action.created, pendingFields: { ...(shouldCreateNewMoneyRequestReport ? {createChat: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD} : {preview: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE}), }, @@ -641,33 +664,33 @@ function buildOnyxDataForMoneyRequest( isNewChatReport ? { onyxMethod: Onyx.METHOD.SET, - key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${chatReport?.reportID}`, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${chat.report?.reportID}`, value: { - [chatCreatedAction.reportActionID]: chatCreatedAction, - [reportPreviewAction.reportActionID]: reportPreviewAction, + [chat.createdAction.reportActionID]: chat.createdAction, + [chat.reportPreviewAction.reportActionID]: chat.reportPreviewAction, }, } : { onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${chatReport?.reportID}`, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${chat.report?.reportID}`, value: { - [reportPreviewAction.reportActionID]: reportPreviewAction, + [chat.reportPreviewAction.reportActionID]: chat.reportPreviewAction, }, }, shouldCreateNewMoneyRequestReport ? { onyxMethod: Onyx.METHOD.SET, - key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${iouReport.reportID}`, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${iou.report.reportID}`, value: { - [iouCreatedAction.reportActionID]: iouCreatedAction as OnyxTypes.ReportAction, - [iouAction.reportActionID]: iouAction as OnyxTypes.ReportAction, + [iou.createdAction.reportActionID]: iou.createdAction as OnyxTypes.ReportAction, + [iou.action.reportActionID]: iou.action as OnyxTypes.ReportAction, }, } : { onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${iouReport.reportID}`, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${iou.report.reportID}`, value: { - [iouAction.reportActionID]: iouAction as OnyxTypes.ReportAction, + [iou.action.reportActionID]: iou.action as OnyxTypes.ReportAction, }, }, { @@ -690,36 +713,36 @@ function buildOnyxDataForMoneyRequest( }); } - if (optimisticPolicyRecentlyUsedCategories.length) { + if (policyRecentlyUsed.categories?.length) { optimisticData.push({ onyxMethod: Onyx.METHOD.SET, - key: `${ONYXKEYS.COLLECTION.POLICY_RECENTLY_USED_CATEGORIES}${iouReport.policyID}`, - value: optimisticPolicyRecentlyUsedCategories, + key: `${ONYXKEYS.COLLECTION.POLICY_RECENTLY_USED_CATEGORIES}${iou.report.policyID}`, + value: policyRecentlyUsed.categories, }); } - if (optimisticRecentlyUsedCurrencies?.length) { + if (policyRecentlyUsed.currencies?.length) { optimisticData.push({ onyxMethod: Onyx.METHOD.SET, key: ONYXKEYS.RECENTLY_USED_CURRENCIES, - value: optimisticRecentlyUsedCurrencies, + value: policyRecentlyUsed.currencies, }); } - if (!isEmptyObject(optimisticPolicyRecentlyUsedTags)) { + if (!isEmptyObject(policyRecentlyUsed.tags)) { optimisticData.push({ onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.POLICY_RECENTLY_USED_TAGS}${iouReport.policyID}`, - value: optimisticPolicyRecentlyUsedTags, + key: `${ONYXKEYS.COLLECTION.POLICY_RECENTLY_USED_TAGS}${iou.report.policyID}`, + value: policyRecentlyUsed.tags, }); } const redundantParticipants: Record = {}; - if (!isEmptyObject(optimisticPersonalDetailListAction)) { + if (!isEmptyObject(personalDetailListAction)) { const successPersonalDetailListAction: Record = {}; // BE will send different participants. We clear the optimistic ones to avoid duplicated entries - Object.keys(optimisticPersonalDetailListAction).forEach((accountIDKey) => { + Object.keys(personalDetailListAction).forEach((accountIDKey) => { const accountID = Number(accountIDKey); successPersonalDetailListAction[accountID] = null; redundantParticipants[accountID] = null; @@ -728,7 +751,7 @@ function buildOnyxDataForMoneyRequest( optimisticData.push({ onyxMethod: Onyx.METHOD.MERGE, key: ONYXKEYS.PERSONAL_DETAILS_LIST, - value: optimisticPersonalDetailListAction, + value: personalDetailListAction, }); successData.push({ onyxMethod: Onyx.METHOD.MERGE, @@ -737,11 +760,11 @@ function buildOnyxDataForMoneyRequest( }); } - if (!isEmptyObject(optimisticNextStep)) { + if (!isEmptyObject(nextStep)) { optimisticData.push({ onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.NEXT_STEP}${iouReport.reportID}`, - value: optimisticNextStep, + key: `${ONYXKEYS.COLLECTION.NEXT_STEP}${iou.report.reportID}`, + value: nextStep, }); } @@ -749,7 +772,7 @@ function buildOnyxDataForMoneyRequest( successData.push( { onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT}${chatReport?.reportID}`, + key: `${ONYXKEYS.COLLECTION.REPORT}${chat.report?.reportID}`, value: { participants: redundantParticipants, pendingFields: null, @@ -758,7 +781,7 @@ function buildOnyxDataForMoneyRequest( }, { onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT_METADATA}${chatReport?.reportID}`, + key: `${ONYXKEYS.COLLECTION.REPORT_METADATA}${chat.report?.reportID}`, value: { isOptimisticReport: false, }, @@ -769,7 +792,7 @@ function buildOnyxDataForMoneyRequest( successData.push( { onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT}${iouReport.reportID}`, + key: `${ONYXKEYS.COLLECTION.REPORT}${iou.report.reportID}`, value: { participants: redundantParticipants, pendingFields: null, @@ -778,7 +801,7 @@ function buildOnyxDataForMoneyRequest( }, { onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT_METADATA}${iouReport.reportID}`, + key: `${ONYXKEYS.COLLECTION.REPORT_METADATA}${iou.report.reportID}`, value: { isOptimisticReport: false, }, @@ -814,34 +837,34 @@ function buildOnyxDataForMoneyRequest( { onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${chatReport?.reportID}`, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${chat.report?.reportID}`, value: { ...(isNewChatReport ? { - [chatCreatedAction.reportActionID]: { + [chat.createdAction.reportActionID]: { pendingAction: null, errors: null, }, } : {}), - [reportPreviewAction.reportActionID]: { + [chat.reportPreviewAction.reportActionID]: { pendingAction: null, }, }, }, { onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${iouReport.reportID}`, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${iou.report.reportID}`, value: { ...(shouldCreateNewMoneyRequestReport ? { - [iouCreatedAction.reportActionID]: { + [iou.createdAction.reportActionID]: { pendingAction: null, errors: null, }, } : {}), - [iouAction.reportActionID]: { + [iou.action.reportActionID]: { pendingAction: null, errors: null, }, @@ -867,12 +890,12 @@ function buildOnyxDataForMoneyRequest( const failureData: OnyxUpdate[] = [ { onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT}${chatReport?.reportID}`, + key: `${ONYXKEYS.COLLECTION.REPORT}${chat.report?.reportID}`, value: { - iouReportID: chatReport?.iouReportID, - lastReadTime: chatReport?.lastReadTime, + iouReportID: chat.report?.iouReportID, + lastReadTime: chat.report?.lastReadTime, pendingFields: null, - hasOutstandingChildRequest: chatReport?.hasOutstandingChildRequest, + hasOutstandingChildRequest: chat.report?.hasOutstandingChildRequest, ...(isNewChatReport ? { errorFields: { @@ -884,7 +907,7 @@ function buildOnyxDataForMoneyRequest( }, { onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT}${iouReport.reportID}`, + key: `${ONYXKEYS.COLLECTION.REPORT}${iou.report.reportID}`, value: { pendingFields: null, errorFields: { @@ -916,21 +939,21 @@ function buildOnyxDataForMoneyRequest( }, { onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${iouReport.reportID}`, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${iou.report.reportID}`, value: { ...(shouldCreateNewMoneyRequestReport ? { - [iouCreatedAction.reportActionID]: { + [iou.createdAction.reportActionID]: { // Disabling this line since transaction.filename can be an empty string // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing errors: getReceiptError(transaction.receipt, transaction.filename || transaction.receipt?.filename, isScanRequest, errorKey), }, - [iouAction.reportActionID]: { + [iou.action.reportActionID]: { errors: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('iou.error.genericCreateFailureMessage'), }, } : { - [iouAction.reportActionID]: { + [iou.action.reportActionID]: { // Disabling this line since transaction.filename can be an empty string // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing errors: getReceiptError(transaction.receipt, transaction.filename || transaction.receipt?.filename, isScanRequest, errorKey), @@ -946,7 +969,7 @@ function buildOnyxDataForMoneyRequest( key: ONYXKEYS.NVP_QUICK_ACTION_GLOBAL_CREATE, value: { action: newQuickAction, - chatReportID: chatReport?.reportID, + chatReportID: chat.report?.reportID, isFirstQuickAction: isEmptyObject(quickAction), }, }); @@ -2212,7 +2235,7 @@ function getMoneyRequestInformation(moneyRequestInformation: MoneyRequestInforma const optimisticPolicyRecentlyUsedCategories = Category.buildOptimisticPolicyRecentlyUsedCategories(iouReport.policyID, category); const optimisticPolicyRecentlyUsedTags = Tag.buildOptimisticPolicyRecentlyUsedTags(iouReport.policyID, tag); - const optimisticPolicyRecentluUsedCurrencies = Policy.buildOptimisticRecentlyUsedCurrencies(currency); + const optimisticPolicyRecentlyUsedCurrencies = Policy.buildOptimisticRecentlyUsedCurrencies(currency); // If there is an existing transaction (which is the case for distance requests), then the data from the existing transaction // needs to be manually merged into the optimistic transaction. This is because buildOnyxDataForMoneyRequest() uses `Onyx.set()` for the transaction @@ -2281,29 +2304,39 @@ function getMoneyRequestInformation(moneyRequestInformation: MoneyRequestInforma const optimisticNextStep = NextStepUtils.buildNextStep(iouReport, predictedNextStatus); // STEP 5: Build Onyx Data - const [optimisticData, successData, failureData] = buildOnyxDataForMoneyRequest( - chatReport, - iouReport, - optimisticTransaction, - optimisticCreatedActionForChat, - optimisticCreatedActionForIOUReport, - iouAction, - optimisticPersonalDetailListAction, - reportPreviewAction, - optimisticPolicyRecentlyUsedCategories, - optimisticPolicyRecentlyUsedTags, + const [optimisticData, successData, failureData] = buildOnyxDataForMoneyRequest({ isNewChatReport, - optimisticTransactionThread ?? {}, - optimisticCreatedActionForTransactionThread, shouldCreateNewMoneyRequestReport, - policy, - policyTagList, - policyCategories, - optimisticNextStep, - undefined, - undefined, - optimisticPolicyRecentluUsedCurrencies, - ); + policyParams: { + policy, + policyCategories, + policyTagList, + }, + optimisticParams: { + chat: { + report: chatReport, + createdAction: optimisticCreatedActionForChat, + reportPreviewAction, + }, + iou: { + report: iouReport, + createdAction: optimisticCreatedActionForIOUReport, + action: iouAction, + }, + transactionParams: { + transaction: optimisticTransaction, + transactionThreadReport: optimisticTransactionThread, + transactionThreadCreatedReportAction: optimisticCreatedActionForTransactionThread, + }, + policyRecentlyUsed: { + categories: optimisticPolicyRecentlyUsedCategories, + tags: optimisticPolicyRecentlyUsedTags, + currencies: optimisticPolicyRecentlyUsedCurrencies, + }, + personalDetailListAction: optimisticPersonalDetailListAction, + nextStep: optimisticNextStep, + }, + }); return { payerAccountID, @@ -4443,29 +4476,34 @@ function createSplitsAndOnyxData( const optimisticPolicyRecentlyUsedTags = isPolicyExpenseChat ? Tag.buildOptimisticPolicyRecentlyUsedTags(participant.policyID, tag) : {}; // STEP 5: Build Onyx Data - const [oneOnOneOptimisticData, oneOnOneSuccessData, oneOnOneFailureData] = buildOnyxDataForMoneyRequest( - oneOnOneChatReport, - oneOnOneIOUReport, - oneOnOneTransaction, - oneOnOneCreatedActionForChat, - oneOnOneCreatedActionForIOU, - oneOnOneIOUAction, - oneOnOnePersonalDetailListAction, - oneOnOneReportPreviewAction, - optimisticPolicyRecentlyUsedCategories, - optimisticPolicyRecentlyUsedTags, - isNewOneOnOneChatReport, - optimisticTransactionThread, - optimisticCreatedActionForTransactionThread, - shouldCreateNewOneOnOneIOUReport, - null, - null, - null, - null, - true, - undefined, - optimisticRecentlyUsedCurrencies, - ); + const [oneOnOneOptimisticData, oneOnOneSuccessData, oneOnOneFailureData] = buildOnyxDataForMoneyRequest({ + isNewChatReport: isNewOneOnOneChatReport, + shouldCreateNewMoneyRequestReport: shouldCreateNewOneOnOneIOUReport, + isOneOnOneSplit: true, + optimisticParams: { + chat: { + report: oneOnOneChatReport, + createdAction: oneOnOneCreatedActionForChat, + reportPreviewAction: oneOnOneReportPreviewAction, + }, + iou: { + report: oneOnOneIOUReport, + createdAction: oneOnOneCreatedActionForIOU, + action: oneOnOneIOUAction, + }, + transactionParams: { + transaction: oneOnOneTransaction, + transactionThreadReport: optimisticTransactionThread, + transactionThreadCreatedReportAction: optimisticCreatedActionForTransactionThread, + }, + policyRecentlyUsed: { + categories: optimisticPolicyRecentlyUsedCategories, + tags: optimisticPolicyRecentlyUsedTags, + currencies: optimisticRecentlyUsedCurrencies, + }, + personalDetailListAction: oneOnOnePersonalDetailListAction, + }, + }); const individualSplit = { email, @@ -5171,27 +5209,29 @@ function completeSplitBill(chatReportID: string, reportAction: OnyxTypes.ReportA oneOnOneReportPreviewAction = ReportUtils.buildOptimisticReportPreview(oneOnOneChatReport, oneOnOneIOUReport, '', oneOnOneTransaction); } - const [oneOnOneOptimisticData, oneOnOneSuccessData, oneOnOneFailureData] = buildOnyxDataForMoneyRequest( - oneOnOneChatReport, - oneOnOneIOUReport, - oneOnOneTransaction, - oneOnOneCreatedActionForChat, - oneOnOneCreatedActionForIOU, - oneOnOneIOUAction, - {}, - oneOnOneReportPreviewAction, - [], - {}, - isNewOneOnOneChatReport, - optimisticTransactionThread, - optimisticCreatedActionForTransactionThread, - shouldCreateNewOneOnOneIOUReport, - null, - null, - null, - null, - true, - ); + const [oneOnOneOptimisticData, oneOnOneSuccessData, oneOnOneFailureData] = buildOnyxDataForMoneyRequest({ + isNewChatReport: isNewOneOnOneChatReport, + isOneOnOneSplit: true, + shouldCreateNewMoneyRequestReport: shouldCreateNewOneOnOneIOUReport, + optimisticParams: { + chat: { + report: oneOnOneChatReport, + createdAction: oneOnOneCreatedActionForChat, + reportPreviewAction: oneOnOneReportPreviewAction, + }, + iou: { + report: oneOnOneIOUReport, + createdAction: oneOnOneCreatedActionForIOU, + action: oneOnOneIOUAction, + }, + transactionParams: { + transaction: oneOnOneTransaction, + transactionThreadReport: optimisticTransactionThread, + transactionThreadCreatedReportAction: optimisticCreatedActionForTransactionThread, + }, + policyRecentlyUsed: {}, + }, + }); splits.push({ email: participant.email, @@ -7568,6 +7608,10 @@ function cancelPayment(expenseReport: OnyxEntry, chatReport: O const stateNum: ValueOf = approvalMode === CONST.POLICY.APPROVAL_MODE.OPTIONAL ? CONST.REPORT.STATE_NUM.SUBMITTED : CONST.REPORT.STATE_NUM.APPROVED; const statusNum: ValueOf = approvalMode === CONST.POLICY.APPROVAL_MODE.OPTIONAL ? CONST.REPORT.STATUS_NUM.CLOSED : CONST.REPORT.STATUS_NUM.APPROVED; const optimisticNextStep = NextStepUtils.buildNextStep(expenseReport, statusNum); + const iouReportActions = ReportActionsUtils.getAllReportActions(chatReport.iouReportID ?? '-1'); + const expenseReportActions = ReportActionsUtils.getAllReportActions(expenseReport.reportID ?? '-1'); + const iouCreatedAction = Object.values(iouReportActions).find((action) => ReportActionsUtils.isCreatedAction(action)); + const expenseCreatedAction = Object.values(expenseReportActions).find((action) => ReportActionsUtils.isCreatedAction(action)); const optimisticData: OnyxUpdate[] = [ { onyxMethod: Onyx.METHOD.MERGE, @@ -7579,6 +7623,14 @@ function cancelPayment(expenseReport: OnyxEntry, chatReport: O }, }, }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${chatReport.reportID}`, + value: { + // The report created later will become the iouReportID of the chat report + iouReportID: (iouCreatedAction?.created ?? '') > (expenseCreatedAction?.created ?? '') ? chatReport?.iouReportID : expenseReport.reportID, + }, + }, { onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.REPORT}${expenseReport.reportID}`, diff --git a/src/libs/actions/Search.ts b/src/libs/actions/Search.ts index bb64fe10db26..c08a83088180 100644 --- a/src/libs/actions/Search.ts +++ b/src/libs/actions/Search.ts @@ -49,16 +49,9 @@ function handleActionButtonPress(hash: number, item: TransactionListItemType | R // The transactionIDList is needed to handle actions taken on `status:all` where transactions on single expense reports can be approved/paid. // We need the transactionID to display the loading indicator for that list item's action. const transactionID = isTransactionListItemType(item) ? [item.transactionID] : undefined; - const data = (allSnapshots?.[`${ONYXKEYS.COLLECTION.SNAPSHOT}${hash}`]?.data ?? {}) as SearchResults['data']; - const allReportTransactions = ( - isReportListItemType(item) - ? Object.entries(data) - .filter(([itemKey, value]) => itemKey.startsWith(ONYXKEYS.COLLECTION.REPORT) && (value as SearchTransaction)?.reportID === item.reportID) - .map((report) => report[1]) - : [data[`${ONYXKEYS.COLLECTION.TRANSACTION}${item.transactionID}`]] - ) as SearchTransaction[]; - + const allReportTransactions = (isReportListItemType(item) ? item.transactions : [item]) as SearchTransaction[]; const hasHeldExpense = ReportUtils.hasHeldExpenses('', allReportTransactions); + if (hasHeldExpense) { goToItem(); return; diff --git a/src/pages/WorkspaceSwitcherPage/index.tsx b/src/pages/WorkspaceSwitcherPage/index.tsx index 22ac25332b81..87ba17b6504d 100644 --- a/src/pages/WorkspaceSwitcherPage/index.tsx +++ b/src/pages/WorkspaceSwitcherPage/index.tsx @@ -1,3 +1,4 @@ +import {useIsFocused} from '@react-navigation/native'; import React, {useCallback, useMemo} from 'react'; import {useOnyx} from 'react-native-onyx'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; @@ -35,6 +36,7 @@ function WorkspaceSwitcherPage() { const [searchTerm, debouncedSearchTerm, setSearchTerm] = useDebouncedState(''); const {translate} = useLocalize(); const {activeWorkspaceID, setActiveWorkspaceID} = useActiveWorkspace(); + const isFocused = useIsFocused(); const [reports] = useOnyx(ONYXKEYS.COLLECTION.REPORT); const [reportActions] = useOnyx(ONYXKEYS.COLLECTION.REPORT_ACTIONS); @@ -77,6 +79,9 @@ function WorkspaceSwitcherPage() { const selectPolicy = useCallback( (policyID?: string) => { + if (!isFocused) { + return; + } const newPolicyID = policyID === activeWorkspaceID ? undefined : policyID; setActiveWorkspaceID(newPolicyID); @@ -85,7 +90,7 @@ function WorkspaceSwitcherPage() { Navigation.navigateWithSwitchPolicyID({policyID: newPolicyID}); } }, - [activeWorkspaceID, setActiveWorkspaceID], + [activeWorkspaceID, setActiveWorkspaceID, isFocused], ); const usersWorkspaces = useMemo(() => { diff --git a/src/pages/iou/request/step/IOURequestStepDistance.tsx b/src/pages/iou/request/step/IOURequestStepDistance.tsx index efe5d293036b..9e18a74246e9 100644 --- a/src/pages/iou/request/step/IOURequestStepDistance.tsx +++ b/src/pages/iou/request/step/IOURequestStepDistance.tsx @@ -445,7 +445,7 @@ function IOURequestStepDistance({ setShouldShowAtLeastTwoDifferentWaypointsError(true); return; } - if (!isCreatingNewRequest) { + if (!isCreatingNewRequest && !isEditing) { transactionWasSaved.current = true; } if (isEditing) { @@ -466,6 +466,7 @@ function IOURequestStepDistance({ ...(hasRouteChanged ? {routes: transaction?.routes} : {}), policy, }); + transactionWasSaved.current = true; navigateBack(); return; } diff --git a/src/types/onyx/SearchResults.ts b/src/types/onyx/SearchResults.ts index 9a72574ab0a7..a47c0c5cab49 100644 --- a/src/types/onyx/SearchResults.ts +++ b/src/types/onyx/SearchResults.ts @@ -307,7 +307,7 @@ type SearchTransaction = { hasEReceipt?: boolean; /** The transaction description */ - description: string; + description?: string; /** The transaction sender ID */ accountID: number; @@ -315,8 +315,8 @@ type SearchTransaction = { /** The transaction recipient ID */ managerID: number; - /** If the transaction has a Ereceipt */ - hasViolation: boolean; + /** If the transaction has violations */ + hasViolation?: boolean; /** The transaction tax amount */ taxAmount?: number; diff --git a/tests/actions/IOUTest.ts b/tests/actions/IOUTest.ts index e8cd65123656..11ff821cb240 100644 --- a/tests/actions/IOUTest.ts +++ b/tests/actions/IOUTest.ts @@ -1907,6 +1907,96 @@ describe('actions/IOU', () => { }); }); + describe('a workspace chat with a cancelled payment', () => { + const amount = 10000; + const comment = '💸💸💸💸'; + const merchant = 'NASDAQ'; + + afterEach(() => { + mockFetch?.resume?.(); + }); + + it("has an iouReportID of the cancelled payment's expense report", () => { + let expenseReport: OnyxEntry; + let chatReport: OnyxEntry; + + // Given a signed in account, which owns a workspace, and has a policy expense chat + Onyx.set(ONYXKEYS.SESSION, {email: CARLOS_EMAIL, accountID: CARLOS_ACCOUNT_ID}); + return waitForBatchedUpdates() + .then(() => { + // Which owns a workspace + PolicyActions.createWorkspace(CARLOS_EMAIL, true, "Carlos's Workspace"); + return waitForBatchedUpdates(); + }) + .then(() => + TestHelper.getOnyxData({ + key: ONYXKEYS.COLLECTION.REPORT, + waitForCollectionCallback: true, + callback: (allReports) => { + chatReport = Object.values(allReports ?? {}).find((report) => report?.chatType === CONST.REPORT.CHAT_TYPE.POLICY_EXPENSE_CHAT); + }, + }), + ) + .then(() => { + if (chatReport) { + // When an IOU expense is submitted to that policy expense chat + IOU.requestMoney({ + report: chatReport, + participantParams: { + payeeEmail: RORY_EMAIL, + payeeAccountID: RORY_ACCOUNT_ID, + participant: {login: CARLOS_EMAIL, accountID: CARLOS_ACCOUNT_ID}, + }, + transactionParams: { + amount, + attendees: [], + currency: CONST.CURRENCY.USD, + created: '', + merchant, + comment, + }, + }); + } + return waitForBatchedUpdates(); + }) + .then(() => + // And given an expense report has now been created which holds the IOU + TestHelper.getOnyxData({ + key: ONYXKEYS.COLLECTION.REPORT, + waitForCollectionCallback: true, + callback: (allReports) => { + expenseReport = Object.values(allReports ?? {}).find((report) => report?.type === CONST.REPORT.TYPE.IOU); + }, + }), + ) + .then(() => { + // When the expense report is paid elsewhere (but really, any payment option would work) + if (chatReport && expenseReport) { + IOU.payMoneyRequest(CONST.IOU.PAYMENT_TYPE.ELSEWHERE, chatReport, expenseReport); + } + return waitForBatchedUpdates(); + }) + .then(() => { + if (chatReport && expenseReport) { + // And when the payment is cancelled + IOU.cancelPayment(expenseReport, chatReport); + } + return waitForBatchedUpdates(); + }) + .then(() => + TestHelper.getOnyxData({ + key: ONYXKEYS.COLLECTION.REPORT, + waitForCollectionCallback: true, + callback: (allReports) => { + const chatReportData = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${chatReport?.reportID}`]; + // Then the policy expense chat report has the iouReportID of the IOU expense report + expect(chatReportData?.iouReportID).toBe(expenseReport?.reportID); + }, + }), + ); + }); + }); + describe('deleteMoneyRequest', () => { const amount = 10000; const comment = 'Send me money please'; diff --git a/tests/ui/UnreadIndicatorsTest.tsx b/tests/ui/UnreadIndicatorsTest.tsx index e6acd3e9a19d..f7f4574b1d29 100644 --- a/tests/ui/UnreadIndicatorsTest.tsx +++ b/tests/ui/UnreadIndicatorsTest.tsx @@ -182,7 +182,11 @@ function signInAndGetAppWithUnreadChat(): Promise { } describe('Unread Indicators', () => { - afterEach(() => { + beforeAll(() => { + PusherHelper.setup(); + }); + + beforeEach(() => { jest.clearAllMocks(); Onyx.clear(); diff --git a/tests/ui/WorkspaceSwitcherTest.tsx b/tests/ui/WorkspaceSwitcherTest.tsx new file mode 100644 index 000000000000..614ed4e5ab70 --- /dev/null +++ b/tests/ui/WorkspaceSwitcherTest.tsx @@ -0,0 +1,104 @@ +import * as NativeNavigation from '@react-navigation/native'; +import {act, fireEvent, render, screen} from '@testing-library/react-native'; +import React from 'react'; +import Onyx from 'react-native-onyx'; +import * as Localize from '@libs/Localize'; +import type Navigation from '@libs/Navigation/Navigation'; +import * as AppActions from '@userActions/App'; +import * as User from '@userActions/User'; +import App from '@src/App'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {NativeNavigationMock} from '../../__mocks__/@react-navigation/native'; +import * as LHNTestUtils from '../utils/LHNTestUtils'; +import PusherHelper from '../utils/PusherHelper'; +import * as TestHelper from '../utils/TestHelper'; +import waitForBatchedUpdates from '../utils/waitForBatchedUpdates'; +import waitForBatchedUpdatesWithAct from '../utils/waitForBatchedUpdatesWithAct'; + +// We need a large timeout here as we are lazy loading React Navigation screens and this test is running against the entire mounted App +jest.setTimeout(60000); + +jest.mock('@react-navigation/native', () => { + const actualNav = jest.requireActual('@react-navigation/native'); + return { + ...actualNav, + useIsFocused: jest.fn(), + triggerTransitionEnd: jest.fn(), + }; +}); +TestHelper.setupApp(); + +async function signInAndGetApp(): Promise { + // Render the App and sign in as a test user. + render(); + await waitForBatchedUpdatesWithAct(); + + const hintText = Localize.translateLocal('loginForm.loginForm'); + const loginForm = await screen.findAllByLabelText(hintText); + expect(loginForm).toHaveLength(1); + + await act(async () => { + await TestHelper.signInWithTestUser(); + }); + await waitForBatchedUpdatesWithAct(); + + User.subscribeToUserEvents(); + await waitForBatchedUpdates(); + + AppActions.setSidebarLoaded(); + + await waitForBatchedUpdatesWithAct(); +} + +async function navigateToWorkspaceSwitcher(): Promise { + const workspaceSwitcherButton = await screen.findByTestId('WorkspaceSwitcherButton'); + fireEvent(workspaceSwitcherButton, 'press'); + await act(() => { + (NativeNavigation as NativeNavigationMock).triggerTransitionEnd(); + }); + await waitForBatchedUpdatesWithAct(); +} + +describe('WorkspaceSwitcherPage', () => { + beforeEach(() => { + jest.clearAllMocks(); + Onyx.clear(); + + // Unsubscribe to pusher channels + PusherHelper.teardown(); + }); + + it('navigates away when a workspace is selected and `isFocused` is true', async () => { + await signInAndGetApp(); + (NativeNavigation.useIsFocused as jest.Mock).mockReturnValue(true); + + await Onyx.mergeCollection(ONYXKEYS.COLLECTION.POLICY, { + [`${ONYXKEYS.COLLECTION.POLICY}1` as const]: LHNTestUtils.getFakePolicy('1', 'Workspace A'), + [`${ONYXKEYS.COLLECTION.POLICY}2` as const]: LHNTestUtils.getFakePolicy('2', 'Workspace B'), + [`${ONYXKEYS.COLLECTION.POLICY}3` as const]: LHNTestUtils.getFakePolicy('3', 'Workspace C'), + }); + + await navigateToWorkspaceSwitcher(); + + const workspaceRowB = screen.getByLabelText('Workspace B'); + fireEvent.press(workspaceRowB); + expect(screen.queryByTestId('WorkspaceSwitcherPage')).toBeNull(); + }); + + it('does not navigate away when a workspace is selected and `isFocused` is false', async () => { + await signInAndGetApp(); + (NativeNavigation.useIsFocused as jest.Mock).mockReturnValue(false); + + await Onyx.mergeCollection(ONYXKEYS.COLLECTION.POLICY, { + [`${ONYXKEYS.COLLECTION.POLICY}1` as const]: LHNTestUtils.getFakePolicy('1', 'Workspace A'), + [`${ONYXKEYS.COLLECTION.POLICY}2` as const]: LHNTestUtils.getFakePolicy('2', 'Workspace B'), + [`${ONYXKEYS.COLLECTION.POLICY}3` as const]: LHNTestUtils.getFakePolicy('3', 'Workspace C'), + }); + + await navigateToWorkspaceSwitcher(); + + const workspaceRowB = screen.getByLabelText('Workspace B'); + fireEvent.press(workspaceRowB); + expect(screen.getByTestId('WorkspaceSwitcherPage')).toBeOnTheScreen(); + }); +}); diff --git a/tests/unit/Search/handleActionButtonPressTest.ts b/tests/unit/Search/handleActionButtonPressTest.ts new file mode 100644 index 000000000000..69af0e83849a --- /dev/null +++ b/tests/unit/Search/handleActionButtonPressTest.ts @@ -0,0 +1,217 @@ +/* eslint-disable @typescript-eslint/naming-convention */ +import type {ReportListItemType} from '@components/SelectionList/types'; +import {handleActionButtonPress} from '@libs/actions/Search'; + +const mockReportItemWithHold = { + shouldAnimateInHighlight: false, + accountID: 1206, + action: 'approve', + chatReportID: '2108006919825366', + created: '2024-12-04 23:18:33', + currency: 'USD', + isOneTransactionReport: false, + isPolicyExpenseChat: false, + isWaitingOnBankAccount: false, + managerID: 1206, + nonReimbursableTotal: 0, + ownerAccountID: 1206, + policyID: '48D7178DE42EE9F9', + private_isArchived: '', + reportID: '1350959062018695', + reportName: 'Expense Report #1350959062018695', + stateNum: 1, + statusNum: 1, + total: -13500, + type: 'expense', + unheldTotal: -12300, + keyForList: '1350959062018695', + from: { + accountID: 1206, + avatar: 'https://d2k5nsl2zxldvw.cloudfront.net/images/avatars/default-avatar_7.png', + displayName: 'aesf', + firstName: 'aesf', + lastName: '', + login: 'apb@apb.com', + pronouns: '', + timezone: { + automatic: true, + selected: 'America/Edmonton', + }, + phoneNumber: '', + validated: false, + }, + to: { + accountID: 1206, + avatar: 'https://d2k5nsl2zxldvw.cloudfront.net/images/avatars/default-avatar_7.png', + displayName: 'aesf', + firstName: 'aesf', + lastName: '', + login: 'apb@apb.com', + pronouns: '', + timezone: { + automatic: true, + selected: 'America/Edmonton', + }, + phoneNumber: '', + validated: false, + }, + transactions: [ + { + accountID: 1206, + action: 'view', + amount: -1200, + canDelete: true, + canHold: false, + canUnhold: true, + category: '', + comment: { + comment: '', + hold: '3042630993757922770', + }, + created: '2024-12-04', + currency: 'USD', + hasEReceipt: false, + isFromOneTransactionReport: false, + managerID: 1206, + merchant: 'qewr', + modifiedAmount: 0, + modifiedCreated: '', + modifiedCurrency: '', + modifiedMerchant: '', + parentTransactionID: '', + policyID: '48D7178DE42EE9F9', + reportID: '1350959062018695', + reportType: 'expense', + tag: '', + transactionID: '1049531721038862176', + transactionThreadReportID: '2957345659269055', + transactionType: 'cash', + from: { + accountID: 1206, + avatar: 'https://d2k5nsl2zxldvw.cloudfront.net/images/avatars/default-avatar_7.png', + displayName: 'aesf', + firstName: 'aesf', + lastName: '', + login: 'apb@apb.com', + pronouns: '', + timezone: { + automatic: true, + selected: 'America/Edmonton', + }, + phoneNumber: '', + validated: false, + }, + to: { + accountID: 1206, + avatar: 'https://d2k5nsl2zxldvw.cloudfront.net/images/avatars/default-avatar_7.png', + displayName: 'aesf', + firstName: 'aesf', + lastName: '', + login: 'apb@apb.com', + pronouns: '', + timezone: { + automatic: true, + selected: 'America/Edmonton', + }, + phoneNumber: '', + validated: false, + }, + formattedFrom: 'aesf', + formattedTo: 'aesf', + formattedTotal: 1200, + formattedMerchant: 'qewr', + date: '2024-12-04', + shouldShowMerchant: true, + shouldShowCategory: true, + shouldShowTag: false, + shouldShowTax: false, + keyForList: '1049531721038862176', + shouldShowYear: false, + shouldAnimateInHighlight: false, + }, + { + accountID: 1206, + action: 'view', + amount: -12300, + canDelete: true, + canHold: true, + canUnhold: false, + category: '', + comment: { + comment: '', + }, + created: '2024-12-04', + currency: 'USD', + hasEReceipt: false, + isFromOneTransactionReport: false, + managerID: 1206, + merchant: 'fgdfgadfaf', + modifiedAmount: 0, + modifiedCreated: '', + modifiedCurrency: '', + modifiedMerchant: '', + parentTransactionID: '', + policyID: '48D7178DE42EE9F9', + reportID: '1350959062018695', + reportType: 'expense', + tag: '', + transactionID: '5345995386715609966', + transactionThreadReportID: '740282333335072', + transactionType: 'cash', + from: { + accountID: 1206, + avatar: 'https://d2k5nsl2zxldvw.cloudfront.net/images/avatars/default-avatar_7.png', + displayName: 'aesf', + login: 'apb@apb.com', + }, + to: { + accountID: 1206, + avatar: 'https://d2k5nsl2zxldvw.cloudfront.net/images/avatars/default-avatar_7.png', + displayName: 'aesf', + }, + formattedFrom: 'aesf', + formattedTo: 'aesf', + formattedTotal: 12300, + formattedMerchant: 'fgdfgadfaf', + date: '2024-12-04', + shouldShowMerchant: true, + shouldShowCategory: true, + shouldShowTag: false, + shouldShowTax: false, + keyForList: '5345995386715609966', + shouldShowYear: false, + shouldAnimateInHighlight: false, + }, + ], + isSelected: false, +} as ReportListItemType; + +const updatedMockReportItem = { + ...mockReportItemWithHold, + transactions: mockReportItemWithHold.transactions.map((transaction, index) => { + if (index === 0) { + return { + ...transaction, + comment: { + comment: '', + }, + }; + } + return transaction; + }), +}; + +describe('handleActionButtonPress', () => { + const searchHash = 1; + test('Should navigate to item when report has one transaction on hold', () => { + const goToItem = jest.fn(() => {}); + handleActionButtonPress(searchHash, mockReportItemWithHold, goToItem); + expect(goToItem).toHaveBeenCalledTimes(1); + }); + + test('Should not navigate to item when the hold is removed', () => { + const goToItem = jest.fn(() => {}); + handleActionButtonPress(searchHash, updatedMockReportItem, goToItem); + expect(goToItem).toHaveBeenCalledTimes(0); + }); +}); diff --git a/tests/utils/PusherHelper.ts b/tests/utils/PusherHelper.ts index 9f0f71baafd3..8547c25b1235 100644 --- a/tests/utils/PusherHelper.ts +++ b/tests/utils/PusherHelper.ts @@ -22,6 +22,8 @@ function setup() { cluster: CONFIG.PUSHER.CLUSTER, authEndpoint: `${CONFIG.EXPENSIFY.DEFAULT_API_ROOT}api/AuthenticatePusher?`, }); + + window.getPusherInstance()?.connection.emit('connected'); } function emitOnyxUpdate(args: OnyxServerUpdate[]) { diff --git a/tests/utils/TestHelper.ts b/tests/utils/TestHelper.ts index 394c05fe8b7c..f3814d4b91cb 100644 --- a/tests/utils/TestHelper.ts +++ b/tests/utils/TestHelper.ts @@ -2,6 +2,7 @@ import {fireEvent, screen} from '@testing-library/react-native'; import {Str} from 'expensify-common'; import {Linking} from 'react-native'; import Onyx from 'react-native-onyx'; +import type {ConnectOptions} from 'react-native-onyx/dist/types'; import type {ApiCommand, ApiRequestCommandParameters} from '@libs/API/types'; import * as Localize from '@libs/Localize'; import * as Pusher from '@libs/Pusher/pusher'; @@ -12,6 +13,7 @@ import * as Session from '@src/libs/actions/Session'; import HttpUtils from '@src/libs/HttpUtils'; import * as NumberUtils from '@src/libs/NumberUtils'; import ONYXKEYS from '@src/ONYXKEYS'; +import type {OnyxKey} from '@src/ONYXKEYS'; import appSetup from '@src/setup'; import type {Response as OnyxResponse, PersonalDetails, Report} from '@src/types/onyx'; import waitForBatchedUpdates from './waitForBatchedUpdates'; @@ -25,6 +27,9 @@ type MockFetch = jest.MockedFn & { mockAPICommand: (command: TCommand, responseHandler: (params: ApiRequestCommandParameters[TCommand]) => OnyxResponse) => void; }; +type ConnectionCallback = NonNullable['callback']>; +type ConnectionCallbackParams = Parameters>; + type QueueItem = { resolve: (value: Partial | PromiseLike>) => void; input: RequestInfo; @@ -65,6 +70,19 @@ function buildPersonalDetails(login: string, accountID: number, firstName = 'Tes }; } +function getOnyxData(options: ConnectOptions) { + return new Promise((resolve) => { + const connectionID = Onyx.connect({ + ...options, + callback: (...params: ConnectionCallbackParams) => { + Onyx.disconnect(connectionID); + (options.callback as (...args: ConnectionCallbackParams) => void)?.(...params); + resolve(); + }, + }); + }); +} + /** * Simulate signing in and make sure all API calls in this flow succeed. Every time we add * a mockImplementationOnce() we are altering what Network.post() will return. @@ -335,4 +353,5 @@ export { expectAPICommandToHaveBeenCalledWith, setupGlobalFetchMock, navigateToSidebarOption, + getOnyxData, };