From f3a2c4e7570e26f3d925336dd843f8a84f73238a Mon Sep 17 00:00:00 2001 From: Issa Nimaga Date: Wed, 14 Oct 2026 15:07:27 +0100 Subject: [PATCH 1/6] Add Exact Online waitlist --- .../exact-online-icon-square.svg | 1 + src/CONST/index.ts | 4 + src/ONYXKEYS.ts | 4 + .../Icon/chunks/expensify-icons.chunk.ts | 2 + .../MenuItem/layout/MenuItemRoot.tsx | 7 +- src/languages/TranslationTypes.ts | 3 +- src/languages/de.ts | 6 + src/languages/el.ts | 6 + src/languages/en.ts | 6 + src/languages/es.ts | 6 + src/languages/fr.ts | 6 + src/languages/it.ts | 6 + src/languages/ja.ts | 6 + src/languages/nl.ts | 6 + src/languages/pl.ts | 6 + src/languages/pt-BR.ts | 6 + src/languages/zh-hans.ts | 6 + src/libs/actions/User.ts | 12 +- .../accounting/ExactOnlineWaitlistButton.tsx | 73 +++++++++++ .../accounting/PolicyAccountingPage.tsx | 31 ++++- .../workspace/connections/ConnectionCard.tsx | 46 ++++--- src/pages/workspace/connections/types.ts | 10 +- ...ts => useAccountingConnectionListings.tsx} | 17 ++- tests/actions/UserTest.ts | 41 ++++++ .../unit/ExactOnlineConnectionListingTest.tsx | 100 ++++++++++++++ tests/unit/ExactOnlineWaitlistButtonTest.tsx | 123 ++++++++++++++++++ .../PolicyAccountingPageAutoStartFlowTest.tsx | 20 ++- .../useAccountingConnectionListingsTest.ts | 6 +- tests/unit/useMCPConnectionListingsTest.ts | 6 +- tests/unit/useMergeConnectionListingsTest.ts | 6 +- ...useReceiptPartnerConnectionListingsTest.ts | 4 +- 31 files changed, 546 insertions(+), 36 deletions(-) create mode 100644 assets/images/integrationicons/exact-online-icon-square.svg create mode 100644 src/pages/workspace/accounting/ExactOnlineWaitlistButton.tsx rename src/pages/workspace/connections/{useAccountingConnectionListings.ts => useAccountingConnectionListings.tsx} (91%) create mode 100644 tests/unit/ExactOnlineConnectionListingTest.tsx create mode 100644 tests/unit/ExactOnlineWaitlistButtonTest.tsx diff --git a/assets/images/integrationicons/exact-online-icon-square.svg b/assets/images/integrationicons/exact-online-icon-square.svg new file mode 100644 index 000000000000..104d1617da63 --- /dev/null +++ b/assets/images/integrationicons/exact-online-icon-square.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/CONST/index.ts b/src/CONST/index.ts index d01b08ed621f..0c2546259f02 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -1188,6 +1188,7 @@ const CONST = { BULK_SUBMIT_APPROVE_PAY: 'bulkSubmitApprovePay', VENDOR_MATCHING: 'vendorMatching', BUSINESS_CENTRAL: 'businessCentral', + EXACT_ONLINE: 'exactOnline2026', COMMUTER_EXCLUSIONS: 'commuterExclusions', COMMUTER_EXCLUSIONS_ARRANGEMENTS: 'commuterExclusionsArrangements', MULTIPLE_APPROVERS: 'multipleApprovers', @@ -4996,6 +4997,7 @@ const CONST = { CAMPFIRE: 'campfire', ZOHO_BOOKS: 'zohoBooks', BUSINESS_CENTRAL: 'businessCentral', + EXACT_ONLINE: 'exactOnline', GUSTO: 'gusto', ZENEFITS: 'zenefits', MERGE_HR: 'merge_hris', @@ -5034,6 +5036,7 @@ const CONST = { campfire: 'Campfire', zohoBooks: 'Zoho Books', businessCentral: 'Dynamics 365 Business Central', + exactOnline: 'Exact Online', gusto: 'Gusto', billCom: 'Bill.com', zenefits: 'TriNet', @@ -10208,6 +10211,7 @@ const CONST = { }, ACCOUNTING: { SETUP_BUTTON: 'WorkspaceAccounting-SetupButton', + EXACT_ONLINE_WAITLIST_BUTTON: 'WorkspaceAccounting-ExactOnlineWaitlistButton', CARD_SECTION_ADD_BUTTON: 'WorkspaceAccounting-CardSectionAddButton', THREE_DOT_MENU: 'WorkspaceAccounting-ThreeDotMenu', }, diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts index 55dd5da845a8..8faeb930f090 100755 --- a/src/ONYXKEYS.ts +++ b/src/ONYXKEYS.ts @@ -193,6 +193,9 @@ const ONYXKEYS = { /** NVP keys */ + /** UTC signup timestamp for the account's Exact Online waitlist membership */ + NVP_EXACT_ONLINE_WAITLIST: 'nvp_expensify_exactOnlineWaitlist', + /** This NVP contains list of at most 5 recent attendees */ NVP_RECENT_ATTENDEES: 'nvp_expensify_recentAttendees', @@ -1768,6 +1771,7 @@ type OnyxValuesMapping = { // The value of this nvp is a string representation of the date when the block expires, or an empty string if the user is not blocked [ONYXKEYS.NVP_BLOCKED_FROM_CHAT]: string; [ONYXKEYS.NVP_PRIVATE_PUSH_NOTIFICATION_ID]: string; + [ONYXKEYS.NVP_EXACT_ONLINE_WAITLIST]: string; [ONYXKEYS.NVP_RECENT_ATTENDEES]: Attendee[]; [ONYXKEYS.NVP_TRY_FOCUS_MODE]: boolean; [ONYXKEYS.NVP_DISMISSED_HOLD_USE_EXPLANATION]: boolean; diff --git a/src/components/Icon/chunks/expensify-icons.chunk.ts b/src/components/Icon/chunks/expensify-icons.chunk.ts index 48cf25d3c3cd..a07fa8396598 100644 --- a/src/components/Icon/chunks/expensify-icons.chunk.ts +++ b/src/components/Icon/chunks/expensify-icons.chunk.ts @@ -145,6 +145,7 @@ import CircleSlash from '@assets/images/integrationicons/circle-slash.svg'; import ClaudeSquare from '@assets/images/integrationicons/claude-icon-square.svg'; import CursorSquare from '@assets/images/integrationicons/cursor-icon-square.svg'; import DualEntrySquare from '@assets/images/integrationicons/dualentry-icon-square.svg'; +import ExactOnlineSquare from '@assets/images/integrationicons/exact-online-icon-square.svg'; import NetSuiteExport from '@assets/images/integrationicons/export/netsuite-icon.svg'; import QBOExport from '@assets/images/integrationicons/export/qbo-icon.svg'; import SageIntacctExport from '@assets/images/integrationicons/export/sage-intacct-icon.svg'; @@ -505,6 +506,7 @@ const Expensicons = { ReceiptMultiple, RilletSquare, DualEntrySquare, + ExactOnlineSquare, Rotate, RotateLeft, Scan, diff --git a/src/components/MenuItem/layout/MenuItemRoot.tsx b/src/components/MenuItem/layout/MenuItemRoot.tsx index ce2105008840..f4ca4ac6732b 100644 --- a/src/components/MenuItem/layout/MenuItemRoot.tsx +++ b/src/components/MenuItem/layout/MenuItemRoot.tsx @@ -49,9 +49,12 @@ type MenuItemRootProps = PropsWithChildren & /** Accessibility state for the row, e.g. `{expanded}`. Lands on the pressable, which is what a screen reader focuses. */ accessibilityState?: AccessibilityState; + + /** Whether to group the row for accessibility. Disable it when a nested control must receive focus. */ + shouldBeAccessible?: boolean; }; -function MenuItemRoot({children, onPress, isDisabled = false, sentryLabel, testID, accessibilityLabel, style, accessibilityState}: MenuItemRootProps) { +function MenuItemRoot({children, onPress, isDisabled = false, sentryLabel, testID, accessibilityLabel, style, accessibilityState, shouldBeAccessible = true}: MenuItemRootProps) { const styles = useThemeStyles(); const StyleUtils = useStyleUtils(); const pressableRef = useRef>(null); @@ -114,7 +117,7 @@ function MenuItemRoot({children, onPress, isDisabled = false, sentryLabel, testI accessibilityLabel={rowAccessibilityLabel} accessibilityHint={accessibilityHint} accessibilityState={accessibilityState} - accessible + accessible={shouldBeAccessible} tabIndex={isInteractive ? 0 : -1} sentryLabel={sentryLabel} testID={testID} diff --git a/src/languages/TranslationTypes.ts b/src/languages/TranslationTypes.ts index 2ff1b97b0a59..22b6b4eaacc7 100644 --- a/src/languages/TranslationTypes.ts +++ b/src/languages/TranslationTypes.ts @@ -73,7 +73,8 @@ type PersonalRulesModifiedFields = PolicyRulesModifiedFields & { reportName?: string; }; -type ConnectionName = ValueOf; +// Exact Online only has a waitlist until its connector is released. +type ConnectionName = Exclude, typeof CONST.POLICY.CONNECTIONS.NAME.EXACT_ONLINE>; type AllConnectionName = ConnectionName; type PolicyConnectionSyncStage = ValueOf; type SageIntacctMappingName = ValueOf; diff --git a/src/languages/de.ts b/src/languages/de.ts index 9443b7ef5276..ec8fe818eb88 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -7430,6 +7430,12 @@ Der Control-Tarif beginnt bei 9 $ pro aktivem Mitglied und Monat.`, errorODIntegration: (oldDotPolicyConnectionsURL: string) => `Bei einer in Expensify Classic eingerichteten Verbindung ist ein Fehler aufgetreten. [Wechsle zu Expensify Classic, um dieses Problem zu beheben.](${oldDotPolicyConnectionsURL})`, goToODToSettings: 'Wechsle zu Expensify Classic, um deine Einstellungen zu verwalten.', + exactOnline: { + comingSoon: 'Demnächst verfügbar', + joinWaitlist: 'Zur Warteliste anmelden', + onWaitlist: 'Du stehst auf der Warteliste', + waitlistDescription: 'Concierge sendet dir eine Nachricht, sobald diese Integration verfügbar ist, und erklärt dir, wie du sie verbindest.', + }, setup: 'Verbinden', connectToSandbox: 'Mit Sandbox verbinden', lastSync: (relativeDate: string) => `Zuletzt synchronisiert ${relativeDate}`, diff --git a/src/languages/el.ts b/src/languages/el.ts index 45c848e2985e..9da276c4af65 100644 --- a/src/languages/el.ts +++ b/src/languages/el.ts @@ -7560,6 +7560,12 @@ _Για πιο αναλυτικές οδηγίες, [επισκεφθείτε τ errorODIntegration: (oldDotPolicyConnectionsURL: string) => `Προέκυψε σφάλμα με μια σύνδεση που έχει ρυθμιστεί στο Expensify Classic. [Μεταβείτε στο Expensify Classic για να διορθώσετε αυτό το ζήτημα.](${oldDotPolicyConnectionsURL})`, goToODToSettings: 'Μεταβείτε στο Expensify Classic για να διαχειριστείτε τις ρυθμίσεις σας.', + exactOnline: { + comingSoon: 'Σύντομα κοντά σας', + joinWaitlist: 'Εγγραφή στη λίστα αναμονής', + onWaitlist: 'Είστε στη λίστα αναμονής', + waitlistDescription: 'Το Concierge θα σας στείλει ένα μήνυμα για να σας ενημερώσει πότε αυτή η ενσωμάτωση είναι έτοιμη και πώς να συνδεθείτε.', + }, setup: 'Σύνδεση', connectToSandbox: 'Σύνδεση στο sandbox', lastSync: (relativeDate: string) => `Τελευταίος συγχρονισμός ${relativeDate}`, diff --git a/src/languages/en.ts b/src/languages/en.ts index 082a4aed7b8d..e09a6bdf9755 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -7781,6 +7781,12 @@ const translations = { errorODIntegration: (oldDotPolicyConnectionsURL: string) => `There's an error with a connection that's been set up in Expensify Classic. [Go to Expensify Classic to fix this issue.](${oldDotPolicyConnectionsURL})`, goToODToSettings: 'Go to Expensify Classic to manage your settings.', + exactOnline: { + comingSoon: 'Coming soon', + joinWaitlist: 'Join waitlist', + onWaitlist: 'You’re on the waitlist', + waitlistDescription: 'Concierge will send you a message to let you know when this integration is ready and how to connect.', + }, setup: 'Connect', connectToSandbox: 'Connect to sandbox', lastSync: (relativeDate: string) => `Last synced ${relativeDate}`, diff --git a/src/languages/es.ts b/src/languages/es.ts index 5a4f6962d2ca..c1d205a4fd0a 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -7228,6 +7228,12 @@ El plan Controlar empieza en 9 $ por miembro activo al mes.`, errorODIntegration: (oldDotPolicyConnectionsURL) => `Hay un error con una conexión que se ha configurado en Expensify Classic. [Ve a Expensify Classic para solucionar este problema.](${oldDotPolicyConnectionsURL})`, goToODToSettings: 'Ve a Expensify Classic para gestionar tus configuraciones.', + exactOnline: { + comingSoon: 'Próximamente', + joinWaitlist: 'Unirse a la lista de espera', + onWaitlist: 'Estás en la lista de espera', + waitlistDescription: 'Concierge te enviará un mensaje para avisarte cuando esta integración esté lista y cómo conectarla.', + }, setup: 'Configurar', connectToSandbox: 'Conectar con Sandbox', lastSync: (relativeDate) => `Recién sincronizado ${relativeDate}`, diff --git a/src/languages/fr.ts b/src/languages/fr.ts index 1397788a7159..c377a2fe7246 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -7449,6 +7449,12 @@ Le forfait Control commence à 9 $ par Membre actif et par mois.`, errorODIntegration: (oldDotPolicyConnectionsURL: string) => `Une erreur s’est produite avec une connexion configurée dans Expensify Classic. [Accédez à Expensify Classic pour corriger ce problème.](${oldDotPolicyConnectionsURL})`, goToODToSettings: 'Accédez à Expensify Classic pour gérer vos paramètres.', + exactOnline: { + comingSoon: 'Bientôt disponible', + joinWaitlist: 'Rejoindre la liste d’attente', + onWaitlist: 'Vous êtes sur la liste d’attente', + waitlistDescription: 'Concierge vous enverra un message pour vous informer lorsque cette intégration sera prête et vous expliquer comment la connecter.', + }, setup: 'Connecter', connectToSandbox: 'Se connecter à Sandbox', lastSync: (relativeDate: string) => `Dernière synchronisation ${relativeDate}`, diff --git a/src/languages/it.ts b/src/languages/it.ts index 9b5a9461ab45..479fedd24651 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -7387,6 +7387,12 @@ Il piano Control parte da 9 $ al mese per ogni membro attivo.`, errorODIntegration: (oldDotPolicyConnectionsURL: string) => `Si è verificato un errore con una connessione configurata in Expensify Classic. [Vai a Expensify Classic per risolvere questo problema.](${oldDotPolicyConnectionsURL})`, goToODToSettings: 'Vai a Expensify Classic per gestire le tue impostazioni.', + exactOnline: { + comingSoon: 'In arrivo', + joinWaitlist: 'Iscriviti alla lista d’attesa', + onWaitlist: 'Sei nella lista d’attesa', + waitlistDescription: 'Concierge ti invierà un messaggio per avvisarti quando questa integrazione sarà pronta e come collegarla.', + }, setup: 'Connetti', connectToSandbox: 'Connetti alla sandbox', lastSync: (relativeDate: string) => `Ultima sincronizzazione ${relativeDate}`, diff --git a/src/languages/ja.ts b/src/languages/ja.ts index d4b6395a03d0..6f7174b334c9 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -7296,6 +7296,12 @@ Control プランは、アクティブメンバー1人あたり月額 $9 から errorODIntegration: (oldDotPolicyConnectionsURL: string) => `Expensify Classic で設定されている接続にエラーがあります。[この問題を解決するには Expensify Classic に移動してください。](${oldDotPolicyConnectionsURL})`, goToODToSettings: '設定を管理するには、Expensify Classic に移動してください。', + exactOnline: { + comingSoon: '近日公開', + joinWaitlist: 'ウェイトリストに登録', + onWaitlist: 'ウェイトリストに登録されています', + waitlistDescription: 'この連携の準備が整った際に、Conciergeからお知らせと接続方法についてのメッセージをお送りします。', + }, setup: '接続', connectToSandbox: 'Sandbox に接続', lastSync: (relativeDate: string) => `最終同期:${relativeDate}`, diff --git a/src/languages/nl.ts b/src/languages/nl.ts index 4fd0023754ec..5cd9a29417a4 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -7370,6 +7370,12 @@ Het Control-abonnement begint bij $9 per actieve deelnemer per maand.`, errorODIntegration: (oldDotPolicyConnectionsURL: string) => `Er is een fout opgetreden met een verbinding die is ingesteld in Expensify Classic. [Ga naar Expensify Classic om dit probleem op te lossen.](${oldDotPolicyConnectionsURL})`, goToODToSettings: 'Ga naar Expensify Classic om je instellingen te beheren.', + exactOnline: { + comingSoon: 'Binnenkort beschikbaar', + joinWaitlist: 'Aanmelden voor de wachtlijst', + onWaitlist: 'Je staat op de wachtlijst', + waitlistDescription: 'Concierge stuurt je een bericht om je te laten weten wanneer deze integratie klaar is en hoe je verbinding maakt.', + }, setup: 'Verbinden', connectToSandbox: 'Verbinden met sandbox', lastSync: (relativeDate: string) => `Laatst gesynchroniseerd ${relativeDate}`, diff --git a/src/languages/pl.ts b/src/languages/pl.ts index 829cfa260527..657210ff9123 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -7396,6 +7396,12 @@ Plan Control zaczyna się od 9 USD za aktywnego członka miesięcznie.`, errorODIntegration: (oldDotPolicyConnectionsURL: string) => `Wystąpił błąd z połączeniem skonfigurowanym w Expensify Classic. [Przejdź do Expensify Classic, aby rozwiązać ten problem.](${oldDotPolicyConnectionsURL})`, goToODToSettings: 'Przejdź do Expensify Classic, aby zarządzać swoimi ustawieniami.', + exactOnline: { + comingSoon: 'Wkrótce', + joinWaitlist: 'Dołącz do listy oczekujących', + onWaitlist: 'Jesteś na liście oczekujących', + waitlistDescription: 'Concierge wyśle Ci wiadomość, aby poinformować Cię, kiedy ta integracja będzie gotowa i jak ją połączyć.', + }, setup: 'Połącz', connectToSandbox: 'Połącz z sandboxem', lastSync: (relativeDate: string) => `Ostatnia synchronizacja ${relativeDate}`, diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index c48f53b822ab..39a402186950 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -7372,6 +7372,12 @@ O plano Control começa em US$ 9 por membro ativo por mês.`, errorODIntegration: (oldDotPolicyConnectionsURL: string) => `Há um erro em uma conexão configurada no Expensify Classic. [Acesse o Expensify Classic para corrigir esse problema.](${oldDotPolicyConnectionsURL})`, goToODToSettings: 'Vá para o Expensify Classic para gerenciar suas configurações.', + exactOnline: { + comingSoon: 'Em breve', + joinWaitlist: 'Entrar na lista de espera', + onWaitlist: 'Você está na lista de espera', + waitlistDescription: 'O Concierge enviará uma mensagem para avisar quando esta integração estiver pronta e como conectá-la.', + }, setup: 'Conectar', connectToSandbox: 'Conectar ao sandbox', lastSync: (relativeDate: string) => `Última sincronização ${relativeDate}`, diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index 80c14fa5170f..fcdd2e5a389e 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -7113,6 +7113,12 @@ _如需更详细的说明,请[访问我们的帮助网站](${CONST.NETSUITE_IM }, errorODIntegration: (oldDotPolicyConnectionsURL: string) => `Expensify Classic 中已设置的某个连接出错。[前往 Expensify Classic 解决此问题](${oldDotPolicyConnectionsURL})`, goToODToSettings: '前往 Expensify Classic 管理您的设置。', + exactOnline: { + comingSoon: '即将推出', + joinWaitlist: '加入等候名单', + onWaitlist: '你已加入等候名单', + waitlistDescription: '此集成准备就绪后,Concierge 会发送消息通知你,并说明如何连接。', + }, setup: '连接', connectToSandbox: '连接到沙盒', lastSync: (relativeDate: string) => `上次同步于 ${relativeDate}`, diff --git a/src/libs/actions/User.ts b/src/libs/actions/User.ts index 418f1dcd1584..ba5a95ab6dbd 100644 --- a/src/libs/actions/User.ts +++ b/src/libs/actions/User.ts @@ -1283,7 +1283,7 @@ function dismissReferralBanner(type: ValueOf(name: TKey, value: SetNameValuePairParams['value'], revertedValue: SetNameValuePairParams['value'], shouldRevertValue = true) { +function setNameValuePair(name: TKey, value: SetNameValuePairParams['value'], revertedValue: SetNameValuePairParams['value'] | null, shouldRevertValue = true) { const parameters: SetNameValuePairParams = { name, value: typeof value === 'object' && value != null ? JSON.stringify(value) : value, @@ -1313,6 +1313,15 @@ function setNameValuePair(name: TKey, value: SetNameValueP }); } +function joinExactOnlineWaitlist(previousSignupTimestamp: OnyxEntry) { + if (previousSignupTimestamp) { + return; + } + + // The queued request keeps this timestamp when it retries, preserving the original signup time. + setNameValuePair(ONYXKEYS.NVP_EXACT_ONLINE_WAITLIST, DateUtils.getDBTime(), previousSignupTimestamp ?? null); +} + function dismissMarketingWindow(updateKey: string) { const optimisticData: AnyOnyxUpdate[] = [ { @@ -2021,6 +2030,7 @@ export { clearDraftCustomStatus, requestRefund, setNameValuePair, + joinExactOnlineWaitlist, setSubmitMigrationModalShown, clearUnvalidatedNewContactMethodAction, clearPendingContactActionErrors, diff --git a/src/pages/workspace/accounting/ExactOnlineWaitlistButton.tsx b/src/pages/workspace/accounting/ExactOnlineWaitlistButton.tsx new file mode 100644 index 000000000000..7a2077cb43b1 --- /dev/null +++ b/src/pages/workspace/accounting/ExactOnlineWaitlistButton.tsx @@ -0,0 +1,73 @@ +/** Account-wide signup for Exact Online before the accounting connector is available. */ +import Button from '@components/Button'; +import Text from '@components/Text'; + +import useConfirmModal from '@hooks/useConfirmModal'; +import useLocalize from '@hooks/useLocalize'; +import useOnyx from '@hooks/useOnyx'; +import usePolicyFeatureWriteAccess from '@hooks/usePolicyFeatureWriteAccess'; +import useThemeStyles from '@hooks/useThemeStyles'; + +import {joinExactOnlineWaitlist} from '@userActions/User'; + +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {Policy} from '@src/types/onyx'; + +import type {OnyxEntry} from 'react-native-onyx'; + +import React, {useRef} from 'react'; + +type ExactOnlineWaitlistButtonProps = { + policy: OnyxEntry; +}; + +function ExactOnlineWaitlistButton({policy}: ExactOnlineWaitlistButtonProps) { + const styles = useThemeStyles(); + const {translate} = useLocalize(); + const {showConfirmModal} = useConfirmModal(); + const {canWrite, showReadOnlyModal} = usePolicyFeatureWriteAccess(policy, CONST.POLICY.POLICY_FEATURE.ACCOUNTING); + const [signupTimestamp, signupTimestampMetadata] = useOnyx(ONYXKEYS.NVP_EXACT_ONLINE_WAITLIST); + const isJoiningWaitlist = useRef(false); + + if (signupTimestamp) { + return {translate('workspace.accounting.exactOnline.onWaitlist')}; + } + + const joinWaitlist = () => { + if (isJoiningWaitlist.current) { + return; + } + if (!canWrite) { + showReadOnlyModal(); + return; + } + // Prevent repeated presses before the optimistic account value reaches this component. + isJoiningWaitlist.current = true; + joinExactOnlineWaitlist(signupTimestamp); + showConfirmModal({ + title: translate('workspace.accounting.exactOnline.onWaitlist'), + prompt: translate('workspace.accounting.exactOnline.waitlistDescription'), + confirmText: translate('common.buttonConfirm'), + shouldShowCancelButton: false, + }).then(() => { + isJoiningWaitlist.current = false; + }); + }; + + return ( + + ); +} + +export default ExactOnlineWaitlistButton; diff --git a/src/pages/workspace/accounting/PolicyAccountingPage.tsx b/src/pages/workspace/accounting/PolicyAccountingPage.tsx index 76519f4b39af..133eac77d73e 100644 --- a/src/pages/workspace/accounting/PolicyAccountingPage.tsx +++ b/src/pages/workspace/accounting/PolicyAccountingPage.tsx @@ -1,4 +1,6 @@ import ActivityIndicator from '@components/ActivityIndicator'; +import UserAvatar from '@components/Avatar/UserAvatar'; +import Badge from '@components/Badge'; import Button from '@components/Button'; import ButtonDisabledWhenOffline from '@components/Button/composed/ButtonDisabledWhenOffline'; import CollapsibleSection from '@components/CollapsibleSection'; @@ -47,6 +49,7 @@ import {View} from 'react-native'; import type {MenuItemData, PolicyAccountingPageProps} from './types'; import {AccountingContextProvider, useAccountingActions, useAccountingState} from './AccountingContext'; +import ExactOnlineWaitlistButton from './ExactOnlineWaitlistButton'; import useConnectedAccountingIntegration from './useConnectedAccountingIntegration'; type RouteParams = { @@ -74,7 +77,7 @@ function PolicyAccountingPage({policy}: PolicyAccountingPageProps) { const shouldDisconnectIntegrationBeforeConnecting = params?.shouldDisconnectIntegrationBeforeConnecting; const shouldConnectToIntuitEnterpriseSuite = params?.isIntuitEnterpriseSuite === 'true'; const policyID = policy?.id; - const icons = useMemoizedLazyExpensifyIcons(['QuestionMark']); + const icons = useMemoizedLazyExpensifyIcons(['QuestionMark', 'ExactOnlineSquare']); const { accountingIntegrations, connectedIntegration, @@ -398,6 +401,32 @@ function PolicyAccountingPage({policy}: PolicyAccountingPageProps) { /> )} + + + + + + + + {CONST.POLICY.CONNECTIONS.NAME_USER_FRIENDLY.exactOnline} + + + + + + + + {!!account?.guideDetails?.email && !hasAccountingConnections(policy) && canWriteAccounting && ( @@ -74,6 +76,28 @@ function ConnectionCard({listing, shouldUseFullWidth}: ConnectionCardProps) { ); } + let titleAddon; + if (offer) { + titleAddon = ( + + ); + } else if (badgeText) { + titleAddon = ( + + ); + } + return ( - ) : undefined - } + titleAddon={titleAddon} numberOfLinesDescription={1} wrapperStyle={[styles.pv4, styles.ph4]} onPress={status ? onConfigure : onConnect} sentryLabel={CONST.SENTRY_LABEL.WORKSPACE.CONNECTIONS.CARD} - disabled={!status && (isOffline || !!isLoading)} + interactive={!rightComponent} + shouldBeAccessible={rightComponent ? getPlatform() !== CONST.PLATFORM.IOS : undefined} + accessibilityLabel={badgeText ? `${title}, ${badgeText}` : undefined} + disabled={!rightComponent && !status && (isOffline || !!isLoading)} shouldShowRightComponent - rightComponent={status ? statusComponent : connectIcon} + rightComponent={rightComponent ?? (status ? statusComponent : connectIcon)} /> ); diff --git a/src/pages/workspace/connections/types.ts b/src/pages/workspace/connections/types.ts index 23f2b9381c27..95b66156af2f 100644 --- a/src/pages/workspace/connections/types.ts +++ b/src/pages/workspace/connections/types.ts @@ -1,7 +1,7 @@ import type CONST from '@src/CONST'; import type IconAsset from '@src/types/utils/IconAsset'; -import type {ComponentRef} from 'react'; +import type {ComponentRef, ReactNode} from 'react'; import type {View} from 'react-native'; import type {ValueOf} from 'type-fest'; @@ -35,7 +35,13 @@ type ConnectionListing = { /** Only set while the integration is connected to the workspace */ status?: ConnectionStatus; - onConnect: () => void; + onConnect?: () => void; + + /** A non-interactive listing can supply its own action, such as an offline-capable waitlist signup. */ + rightComponent?: ReactNode; + + /** Label beside the provider's name when the integration is not yet available. */ + badgeText?: string; /** Shows an "Offer" badge for a partner promotion. The badge only opens the offer when `onPress` is set. */ offer?: {onPress?: () => void}; diff --git a/src/pages/workspace/connections/useAccountingConnectionListings.ts b/src/pages/workspace/connections/useAccountingConnectionListings.tsx similarity index 91% rename from src/pages/workspace/connections/useAccountingConnectionListings.ts rename to src/pages/workspace/connections/useAccountingConnectionListings.tsx index 71bd9da67ed4..b607a82b3d10 100644 --- a/src/pages/workspace/connections/useAccountingConnectionListings.ts +++ b/src/pages/workspace/connections/useAccountingConnectionListings.tsx @@ -16,6 +16,7 @@ import Navigation from '@libs/Navigation/Navigation'; import {getConnectedIntegration, getIntegrationLastSuccessfulDate} from '@libs/PolicyUtils'; import {useAccountingActions, useAccountingState} from '@pages/workspace/accounting/AccountingContext'; +import ExactOnlineWaitlistButton from '@pages/workspace/accounting/ExactOnlineWaitlistButton'; import {getAccountingIntegrationData, getSynchronizationErrorMessage, isIntuitEnterpriseSuiteConnection} from '@pages/workspace/accounting/utils'; import CONST from '@src/CONST'; @@ -26,6 +27,8 @@ import type {ConnectionName} from '@src/types/onyx/Policy'; import type {OnyxEntry} from 'react-native-onyx'; +import React from 'react'; + import type {ConnectionListing, ConnectionStatus} from './types'; import {getSyncStatusMessage} from './utils'; @@ -55,6 +58,7 @@ function useAccountingConnectionListings(policy: OnyxEntry): ConnectionL 'DualEntrySquare', 'CampfireSquare', 'BusinessCentralSquare', + 'ExactOnlineSquare', ]); if (!policyID) { @@ -121,7 +125,7 @@ function useAccountingConnectionListings(policy: OnyxEntry): ConnectionL ...(name === CONST.POLICY.CONNECTIONS.NAME.QBO ? [{name, isIntuitEnterpriseSuite: true}] : []), ]); - return integrationOptions.flatMap(({name, isIntuitEnterpriseSuite}) => { + const listings: ConnectionListing[] = integrationOptions.flatMap(({name, isIntuitEnterpriseSuite}) => { const integrationData = getAccountingIntegrationData(name, policyID, translate, NO_REUSABLE_CONNECTIONS, { expensifyIcons: accountingIcons, isIntuitEnterpriseSuiteOverride: isIntuitEnterpriseSuite, @@ -156,6 +160,17 @@ function useAccountingConnectionListings(policy: OnyxEntry): ConnectionL }, }; }); + + // The connector flow arrives in a later release, so beta users also see the waitlist for now. + listings.push({ + key: CONST.POLICY.CONNECTIONS.NAME.EXACT_ONLINE, + category: CONST.TAB.CONNECTIONS.ACCOUNTING, + title: CONST.POLICY.CONNECTIONS.NAME_USER_FRIENDLY.exactOnline, + icon: accountingIcons.ExactOnlineSquare, + badgeText: translate('workspace.accounting.exactOnline.comingSoon'), + rightComponent: , + }); + return listings; } export default useAccountingConnectionListings; diff --git a/tests/actions/UserTest.ts b/tests/actions/UserTest.ts index 6bd8b104a21d..b81b44dba825 100644 --- a/tests/actions/UserTest.ts +++ b/tests/actions/UserTest.ts @@ -1,5 +1,6 @@ import * as API from '@libs/API'; import {READ_COMMANDS, SIDE_EFFECT_REQUEST_COMMANDS, WRITE_COMMANDS} from '@libs/API/types'; +import DateUtils from '@libs/DateUtils'; import {getLatestErrorField} from '@libs/ErrorUtils'; import Pusher from '@libs/Pusher'; import PusherUtils from '@libs/PusherUtils'; @@ -37,6 +38,46 @@ describe('actions/User', () => { return Onyx.clear().then(waitForBatchedUpdates); }); + describe('joinExactOnlineWaitlist', () => { + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('keeps an existing signup timestamp without sending another request', () => { + // Given this account already joined the waitlist from another workspace + const signupTimestamp = '2026-10-14 10:00:00.000'; + const getDBTime = jest.spyOn(DateUtils, 'getDBTime'); + + // When the account tries to join again + UserActions.joinExactOnlineWaitlist(signupTimestamp); + + // Then the original signup is preserved without generating a new timestamp or request + expect(getDBTime).not.toHaveBeenCalled(); + expect(mockAPI.write).not.toHaveBeenCalled(); + }); + + it('queues the signup timestamp with a rollback to the absent value', () => { + // Given the account has not joined the waitlist + const signupTimestamp = '2026-10-14 10:00:00.000'; + const getDBTime = jest.spyOn(DateUtils, 'getDBTime').mockReturnValue(signupTimestamp); + + // When the account joins the waitlist + UserActions.joinExactOnlineWaitlist(undefined); + + // Then the queued request and optimistic update share one timestamp, with the previous value restored on failure + expect(getDBTime).toHaveBeenCalledTimes(1); + expect(mockAPI.write).toHaveBeenCalledTimes(1); + expect(mockAPI.write).toHaveBeenCalledWith( + WRITE_COMMANDS.SET_NAME_VALUE_PAIR, + {name: ONYXKEYS.NVP_EXACT_ONLINE_WAITLIST, value: signupTimestamp}, + { + optimisticData: [{onyxMethod: Onyx.METHOD.MERGE, key: ONYXKEYS.NVP_EXACT_ONLINE_WAITLIST, value: signupTimestamp}], + failureData: [{onyxMethod: Onyx.METHOD.MERGE, key: ONYXKEYS.NVP_EXACT_ONLINE_WAITLIST, value: null}], + }, + ); + }); + }); + describe('clearContactMethod', () => { it('should return early when contactMethods array is empty', async () => { // Given an empty array diff --git a/tests/unit/ExactOnlineConnectionListingTest.tsx b/tests/unit/ExactOnlineConnectionListingTest.tsx new file mode 100644 index 000000000000..2678f95fab60 --- /dev/null +++ b/tests/unit/ExactOnlineConnectionListingTest.tsx @@ -0,0 +1,100 @@ +import {fireEvent, render, renderHook, screen} from '@testing-library/react-native'; + +import ComposeProviders from '@components/ComposeProviders'; +import {LocaleContextProvider} from '@components/LocaleContextProvider'; +import OnyxListItemProvider from '@components/OnyxListItemProvider'; + +import ConnectionCard from '@pages/workspace/connections/ConnectionCard'; +import useAccountingConnectionListings from '@pages/workspace/connections/useAccountingConnectionListings'; + +import {joinExactOnlineWaitlist} from '@userActions/User'; + +import CONST from '@src/CONST'; +import IntlStore from '@src/languages/IntlStore'; +import ONYXKEYS from '@src/ONYXKEYS'; + +import React from 'react'; +import Onyx from 'react-native-onyx'; + +import type * as MockUseConfirmModalUtil from '../utils/mockUseConfirmModal'; + +import createRandomPolicy from '../utils/collections/policies'; +import waitForBatchedUpdates from '../utils/waitForBatchedUpdates'; +import waitForBatchedUpdatesWithAct from '../utils/waitForBatchedUpdatesWithAct'; + +let mockEnabledBetas: string[] = []; +const mockStartIntegrationFlow = jest.fn(); +const mockConnect = jest.fn(); + +jest.mock('@userActions/User', () => ({joinExactOnlineWaitlist: jest.fn()})); +jest.mock('@hooks/useConfirmModal', () => { + const {default: mockUseConfirmModal} = jest.requireActual('../utils/mockUseConfirmModal'); + return mockUseConfirmModal; +}); +jest.mock('@hooks/usePermissions', () => ({ + __esModule: true, + default: () => ({isBetaEnabled: (beta: string) => mockEnabledBetas.includes(beta)}), +})); +jest.mock('@hooks/useIsUnifiedConnectionsBetaEnabled', () => () => true); +jest.mock('@hooks/usePolicyFeatureWriteAccess', () => ({ + __esModule: true, + default: () => ({canWrite: true, showReadOnlyModal: jest.fn()}), +})); +jest.mock('@pages/workspace/accounting/AccountingContext', () => ({ + useAccountingActions: () => ({startIntegrationFlow: mockStartIntegrationFlow}), + useAccountingState: () => ({activeIntegration: undefined, popoverAnchorRefs: {current: {}}}), +})); + +function TestWrapper({children}: {children: React.ReactNode}) { + return {children}; +} + +describe('Exact Online in unified Connections', () => { + beforeAll(() => { + Onyx.init({keys: ONYXKEYS}); + return IntlStore.load(CONST.LOCALES.EN); + }); + + beforeEach(async () => { + jest.clearAllMocks(); + await Onyx.clear(); + await waitForBatchedUpdates(); + }); + + it.each([{enabledBetas: []}, {enabledBetas: [CONST.BETAS.EXACT_ONLINE]}])('keeps signup available offline with Exact beta enrollment $enabledBetas', async ({enabledBetas}) => { + // Given unified Connections is enabled, the account is offline, and the Exact beta may be enabled + mockEnabledBetas = enabledBetas; + await Onyx.set(ONYXKEYS.NETWORK, {shouldForceOffline: true}); + const policy = createRandomPolicy(1, CONST.POLICY.TYPE.CORPORATE, 'Test workspace'); + const {result} = renderHook(() => useAccountingConnectionListings(policy), {wrapper: TestWrapper}); + await waitForBatchedUpdatesWithAct(); + const listing = result.current.find((item) => item.key === CONST.POLICY.CONNECTIONS.NAME.EXACT_ONLINE); + if (!listing) { + throw new Error('Exact Online waitlist is missing from Connections'); + } + + // When the connection cards render and the user chooses the waitlist + render( + <> + + + , + {wrapper: TestWrapper}, + ); + await waitForBatchedUpdatesWithAct(); + fireEvent.press(screen.getByRole('button', {name: 'Join waitlist'})); + fireEvent.press(screen.getByRole('button', {name: 'Existing connector', disabled: true})); + + // Then Exact offers coming-soon signup offline while live connectors retain their offline restriction + expect(screen.getByText('Coming soon')).toBeOnTheScreen(); + expect(joinExactOnlineWaitlist).toHaveBeenCalledTimes(1); + expect(mockStartIntegrationFlow).not.toHaveBeenCalled(); + expect(mockConnect).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/unit/ExactOnlineWaitlistButtonTest.tsx b/tests/unit/ExactOnlineWaitlistButtonTest.tsx new file mode 100644 index 000000000000..74948f075973 --- /dev/null +++ b/tests/unit/ExactOnlineWaitlistButtonTest.tsx @@ -0,0 +1,123 @@ +import {act, fireEvent, render, screen} from '@testing-library/react-native'; + +import ComposeProviders from '@components/ComposeProviders'; +import {LocaleContextProvider} from '@components/LocaleContextProvider'; +import OnyxListItemProvider from '@components/OnyxListItemProvider'; + +import ExactOnlineWaitlistButton from '@pages/workspace/accounting/ExactOnlineWaitlistButton'; + +import {joinExactOnlineWaitlist} from '@userActions/User'; + +import CONST from '@src/CONST'; +import IntlStore from '@src/languages/IntlStore'; +import ONYXKEYS from '@src/ONYXKEYS'; + +import React from 'react'; +import Onyx from 'react-native-onyx'; + +import type * as MockUseConfirmModalUtil from '../utils/mockUseConfirmModal'; + +import createRandomPolicy from '../utils/collections/policies'; +import {getShowConfirmModalOption, mockShowConfirmModal, resetMockConfirmModal} from '../utils/mockUseConfirmModal'; +import waitForBatchedUpdates from '../utils/waitForBatchedUpdates'; +import waitForBatchedUpdatesWithAct from '../utils/waitForBatchedUpdatesWithAct'; + +const mockShowReadOnlyModal = jest.fn(); +let mockCanWriteAccounting = true; +const policy = createRandomPolicy(1, CONST.POLICY.TYPE.CORPORATE, 'First workspace'); +const otherPolicy = createRandomPolicy(2, CONST.POLICY.TYPE.CORPORATE, 'Second workspace'); + +jest.mock('@userActions/User', () => ({joinExactOnlineWaitlist: jest.fn()})); +jest.mock('@hooks/useConfirmModal', () => { + const {default: mockUseConfirmModal} = jest.requireActual('../utils/mockUseConfirmModal'); + return mockUseConfirmModal; +}); +jest.mock('@hooks/usePolicyFeatureWriteAccess', () => ({ + __esModule: true, + default: () => ({canWrite: mockCanWriteAccounting, showReadOnlyModal: mockShowReadOnlyModal}), +})); + +function TestWrapper({children}: {children: React.ReactNode}) { + return {children}; +} + +describe('Exact Online waitlist signup', () => { + beforeAll(() => { + Onyx.init({keys: ONYXKEYS}); + return IntlStore.load(CONST.LOCALES.EN); + }); + + beforeEach(async () => { + jest.clearAllMocks(); + resetMockConfirmModal(); + mockCanWriteAccounting = true; + await Onyx.clear(); + await waitForBatchedUpdates(); + }); + + it('joins while offline, shows the acknowledgement once, and shares signup status across workspaces', async () => { + // Given an account that has not joined the waitlist and is currently offline + await Onyx.set(ONYXKEYS.NETWORK, {shouldForceOffline: true}); + const {rerender} = render(, {wrapper: TestWrapper}); + await waitForBatchedUpdatesWithAct(); + + // When the user presses Join waitlist twice before the optimistic value renders + const button = screen.getByRole('button', {name: 'Join waitlist'}); + fireEvent.press(button); + fireEvent.press(button); + + // Then signup and its acknowledgement happen once, even offline + expect(joinExactOnlineWaitlist).toHaveBeenCalledTimes(1); + expect(mockShowConfirmModal).toHaveBeenCalledTimes(1); + expect(getShowConfirmModalOption('title')).toBe('You’re on the waitlist'); + expect(getShowConfirmModalOption('prompt')).toBe('Concierge will send you a message to let you know when this integration is ready and how to connect.'); + expect(getShowConfirmModalOption('shouldShowCancelButton')).toBe(false); + + // When the optimistic signup arrives and the user switches workspaces + await act(() => Onyx.set(ONYXKEYS.NVP_EXACT_ONLINE_WAITLIST, '2026-10-14 12:00:00')); + rerender(); + await waitForBatchedUpdatesWithAct(); + + // Then the same account signup is shown without another action in the new workspace + expect(screen.getByText('You’re on the waitlist')).toBeTruthy(); + expect(screen.queryByRole('button', {name: 'Join waitlist'})).toBeNull(); + expect(joinExactOnlineWaitlist).toHaveBeenCalledTimes(1); + + // When the request fails and its optimistic account value is rolled back + await act(() => Onyx.set(ONYXKEYS.NVP_EXACT_ONLINE_WAITLIST, null)); + await waitForBatchedUpdatesWithAct(); + + // Then signup is offered again + expect(screen.getByRole('button', {name: 'Join waitlist'})).toBeTruthy(); + }); + + it('loads an existing account signup without offering another signup', async () => { + // Given an account that joined the waitlist in an earlier session + await Onyx.set(ONYXKEYS.NVP_EXACT_ONLINE_WAITLIST, '2026-10-13 12:00:00'); + + // When the user opens accounting in another workspace + render(, {wrapper: TestWrapper}); + await waitForBatchedUpdatesWithAct(); + + // Then their stored signup replaces the action without opening an acknowledgement + expect(screen.getByText('You’re on the waitlist')).toBeTruthy(); + expect(screen.queryByRole('button', {name: 'Join waitlist'})).toBeNull(); + expect(joinExactOnlineWaitlist).not.toHaveBeenCalled(); + expect(mockShowConfirmModal).not.toHaveBeenCalled(); + }); + + it('shows the read-only explanation without signing up when accounting write access is denied', async () => { + // Given a workspace where the user has read-only accounting access + mockCanWriteAccounting = false; + render(, {wrapper: TestWrapper}); + await waitForBatchedUpdatesWithAct(); + + // When the user presses Join waitlist + fireEvent.press(screen.getByRole('button', {name: 'Join waitlist'})); + + // Then the standard read-only explanation appears and the account stays off the waitlist + expect(mockShowReadOnlyModal).toHaveBeenCalledTimes(1); + expect(joinExactOnlineWaitlist).not.toHaveBeenCalled(); + expect(mockShowConfirmModal).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/unit/PolicyAccountingPageAutoStartFlowTest.tsx b/tests/unit/PolicyAccountingPageAutoStartFlowTest.tsx index f1b9acae0807..a54cfbe4b53b 100644 --- a/tests/unit/PolicyAccountingPageAutoStartFlowTest.tsx +++ b/tests/unit/PolicyAccountingPageAutoStartFlowTest.tsx @@ -2,7 +2,7 @@ import {fireEvent, render, screen, within} from '@testing-library/react-native'; import ComposeProviders from '@components/ComposeProviders'; import {LocaleContextProvider} from '@components/LocaleContextProvider'; -import type {MenuItemProps} from '@components/MenuItem'; +import type * as MenuItemModule from '@components/MenuItem'; import OnyxListItemProvider from '@components/OnyxListItemProvider'; import Navigation from '@libs/Navigation/Navigation'; @@ -117,9 +117,10 @@ jest.mock('@components/HeaderWithBackButton', () => ({__esModule: true, default: jest.mock('@components/MenuItemList', () => ({__esModule: true, default: () => null})); jest.mock('@components/MenuItem', () => { const {View: MockView} = jest.requireActual<{View: typeof View}>('react-native'); + const {default: ActualMenuItem} = jest.requireActual('@components/MenuItem'); return { __esModule: true, - default: ({title, rightComponent}: MenuItemProps) => {rightComponent}, + default: Object.assign(({title, rightComponent}: MenuItemModule.MenuItemProps) => {rightComponent}, ActualMenuItem), }; }); jest.mock('@components/Section', () => ({__esModule: true, default: ({children}: {children: React.ReactNode}) => children})); @@ -258,4 +259,19 @@ describe('PolicyAccountingPage auto-started connect flow', () => { expect(screen.getByTestId(CONST.POLICY.CONNECTIONS.NAME_USER_FRIENDLY.businessCentral)).toBeOnTheScreen(); expect(screen.getByTestId('Campfire')).toBeOnTheScreen(); }); + + it.each([{enabledBetas: []}, {enabledBetas: [CONST.BETAS.EXACT_ONLINE]}])('offers the Exact Online waitlist with beta enrollment $enabledBetas', async ({enabledBetas}) => { + // Given an eligible workspace and a user who may have the future Exact connector beta + mockEnabledBetas = enabledBetas; + mockRouteParams = {}; + + // When the Accounting page lists its integrations + render(, {wrapper: AccountingTestWrapper}); + await waitForBatchedUpdates(); + + // Then Exact Online offers the waitlist without starting an unimplemented connector flow + expect(screen.getByText('Exact Online')).toBeOnTheScreen(); + expect(screen.getByRole('button', {name: 'Join waitlist'})).toBeOnTheScreen(); + expect(mockStartIntegrationFlow).not.toHaveBeenCalled(); + }); }); diff --git a/tests/unit/useAccountingConnectionListingsTest.ts b/tests/unit/useAccountingConnectionListingsTest.ts index 9e67e7a85e35..c9c6286de564 100644 --- a/tests/unit/useAccountingConnectionListingsTest.ts +++ b/tests/unit/useAccountingConnectionListingsTest.ts @@ -53,7 +53,7 @@ describe('useAccountingConnectionListings', () => { const xero = findListing(CONST.POLICY.CONNECTIONS.NAME.XERO); // When the admin connects Xero - xero?.onConnect(); + xero?.onConnect?.(); // Then Xero's flow starts with nothing to disconnect expect(mockStartIntegrationFlow).toHaveBeenCalledWith({name: CONST.POLICY.CONNECTIONS.NAME.XERO, isIntuitEnterpriseSuite: undefined}); @@ -65,7 +65,7 @@ describe('useAccountingConnectionListings', () => { const xero = findListing(CONST.POLICY.CONNECTIONS.NAME.XERO); // When the admin connects Xero - xero?.onConnect(); + xero?.onConnect?.(); // Then the flow is told to disconnect QuickBooks Online first, which asks the admin before replacing it expect(mockStartIntegrationFlow).toHaveBeenCalledWith({ @@ -82,7 +82,7 @@ describe('useAccountingConnectionListings', () => { const xero = findListing(CONST.POLICY.CONNECTIONS.NAME.XERO); // When they try to connect Xero - xero?.onConnect(); + xero?.onConnect?.(); // Then they are told they can't, and no flow starts expect(mockShowReadOnlyModal).toHaveBeenCalledTimes(1); diff --git a/tests/unit/useMCPConnectionListingsTest.ts b/tests/unit/useMCPConnectionListingsTest.ts index 29c2d0e01c55..21118031e957 100644 --- a/tests/unit/useMCPConnectionListingsTest.ts +++ b/tests/unit/useMCPConnectionListingsTest.ts @@ -33,7 +33,7 @@ describe('useMCPConnectionListings', () => { const claude = result.current.find((listing) => listing.key === 'claude'); // When they connect Claude - claude?.onConnect(); + claude?.onConnect?.(); // Then MCP is turned on and Claude's connect page opens, since Connections has no MCP toggle expect(enablePolicyFeatureForConnection).toHaveBeenCalledWith(policy, CONST.POLICY.MORE_FEATURES.IS_MCP_ENABLED); @@ -46,7 +46,7 @@ describe('useMCPConnectionListings', () => { const mcpGuide = result.current.find((listing) => listing.key === 'mcp'); // When the admin presses "+" on it - mcpGuide?.onConnect(); + mcpGuide?.onConnect?.(); // Then the guide opens, since the client is set up by following it rather than by connecting from here expect(openExternalLink).toHaveBeenCalledWith(CONST.MCP_HELP_URL); @@ -59,7 +59,7 @@ describe('useMCPConnectionListings', () => { const {result} = renderHook(() => useMCPConnectionListings(policy)); // When they try to connect an assistant - result.current.at(0)?.onConnect(); + result.current.at(0)?.onConnect?.(); // Then they are told they can't, and nothing is turned on or opened expect(mockShowReadOnlyModal).toHaveBeenCalledTimes(1); diff --git a/tests/unit/useMergeConnectionListingsTest.ts b/tests/unit/useMergeConnectionListingsTest.ts index 653a95419651..5fd63941924c 100644 --- a/tests/unit/useMergeConnectionListingsTest.ts +++ b/tests/unit/useMergeConnectionListingsTest.ts @@ -63,7 +63,7 @@ describe('useMergeConnectionListings', () => { const {listings, onStartSetup} = renderListings(); // When the admin connects an HR provider - listings.at(0)?.onConnect(); + listings.at(0)?.onConnect?.(); // Then the upgrade page opens instead of the provider's setup expect(Navigation.navigate).toHaveBeenCalledTimes(1); @@ -75,7 +75,7 @@ describe('useMergeConnectionListings', () => { const {listings, onStartSetup} = renderListings(); // When the admin connects Gusto - listings.find((listing) => listing.title === CONST.POLICY.CONNECTIONS.NAME.GUSTO)?.onConnect(); + listings.find((listing) => listing.title === CONST.POLICY.CONNECTIONS.NAME.GUSTO)?.onConnect?.(); // Then HR is turned on and Gusto's setup starts, with nothing to replace expect(enablePolicyFeatureForConnection).toHaveBeenCalledWith(policy, CONST.POLICY.MORE_FEATURES.IS_HR_ENABLED); @@ -91,7 +91,7 @@ describe('useMergeConnectionListings', () => { // When the admin connects another provider and cancels the Replace prompt await act(async () => { - listings.find((listing) => listing.title === CONST.POLICY.CONNECTIONS.NAME.ZENEFITS)?.onConnect(); + listings.find((listing) => listing.title === CONST.POLICY.CONNECTIONS.NAME.ZENEFITS)?.onConnect?.(); await waitForBatchedUpdates(); }); diff --git a/tests/unit/useReceiptPartnerConnectionListingsTest.ts b/tests/unit/useReceiptPartnerConnectionListingsTest.ts index b8db5cebbc8e..e00bfe80f14d 100644 --- a/tests/unit/useReceiptPartnerConnectionListingsTest.ts +++ b/tests/unit/useReceiptPartnerConnectionListingsTest.ts @@ -58,7 +58,7 @@ describe('useReceiptPartnerConnectionListings', () => { const {result} = renderHook(() => useReceiptPartnerConnectionListings(policy)); // When the admin presses "+" on Uber - act(() => result.current.at(0)?.onConnect()); + act(() => result.current.at(0)?.onConnect?.()); // Then the feature is turned on and the Uber connect page opens, since Connections has no toggle for the feature expect(enablePolicyFeatureForConnection).toHaveBeenCalledWith(policy, CONST.POLICY.MORE_FEATURES.ARE_RECEIPT_PARTNERS_ENABLED); @@ -77,7 +77,7 @@ describe('useReceiptPartnerConnectionListings', () => { // When the admin then connects Uber from the page and the connection shows up mockUberState = {...mockUberState, isUberConnected: false}; rerender({}); - act(() => result.current.at(0)?.onConnect()); + act(() => result.current.at(0)?.onConnect?.()); mockUberState = {...mockUberState, isUberConnected: true}; rerender({}); From 8f97d96f6ac64fa4f974a55f1fa0ca129d179469 Mon Sep 17 00:00:00 2001 From: Issa Nimaga Date: Wed, 14 Oct 2026 15:16:27 +0100 Subject: [PATCH 2/6] Register Exact Online waitlist in Onyx exports --- src/libs/ExportOnyxState/masking.ts | 1 + src/pages/workspace/accounting/PolicyAccountingPage.tsx | 1 + src/pages/workspace/connections/ConnectionCard.tsx | 1 + 3 files changed, 3 insertions(+) diff --git a/src/libs/ExportOnyxState/masking.ts b/src/libs/ExportOnyxState/masking.ts index de11f5d10fd5..61eaee65c27b 100644 --- a/src/libs/ExportOnyxState/masking.ts +++ b/src/libs/ExportOnyxState/masking.ts @@ -289,6 +289,7 @@ const safeOnyxKeys = new Set([ ONYXKEYS.NVP_DISMISSED_REJECT_USE_EXPLANATION, ONYXKEYS.NVP_DISMISSED_TRIAL_PAYMENT_REMINDER, ONYXKEYS.NVP_EMPTY_REPORTS_CONFIRMATION_DISMISSED, + ONYXKEYS.NVP_EXACT_ONLINE_WAITLIST, ONYXKEYS.NVP_HAS_SEEDED_MY_EXPENSES_SEARCH, ONYXKEYS.NVP_HAS_SEEN_FOR_YOU_TODO, ONYXKEYS.NVP_HAS_SEEN_TRACK_TRAINING, diff --git a/src/pages/workspace/accounting/PolicyAccountingPage.tsx b/src/pages/workspace/accounting/PolicyAccountingPage.tsx index 133eac77d73e..47fb6011ded2 100644 --- a/src/pages/workspace/accounting/PolicyAccountingPage.tsx +++ b/src/pages/workspace/accounting/PolicyAccountingPage.tsx @@ -403,6 +403,7 @@ function PolicyAccountingPage({policy}: PolicyAccountingPageProps) { )} diff --git a/src/pages/workspace/connections/ConnectionCard.tsx b/src/pages/workspace/connections/ConnectionCard.tsx index 96a6b821c9b4..4e7dc0a9e979 100644 --- a/src/pages/workspace/connections/ConnectionCard.tsx +++ b/src/pages/workspace/connections/ConnectionCard.tsx @@ -134,6 +134,7 @@ function ConnectionCard({listing, shouldUseFullWidth}: ConnectionCardProps) { onPress={status ? onConfigure : onConnect} sentryLabel={CONST.SENTRY_LABEL.WORKSPACE.CONNECTIONS.CARD} interactive={!rightComponent} + // On iOS, disable row grouping when a custom action needs its own VoiceOver focus. shouldBeAccessible={rightComponent ? getPlatform() !== CONST.PLATFORM.IOS : undefined} accessibilityLabel={badgeText ? `${title}, ${badgeText}` : undefined} disabled={!rightComponent && !status && (isOffline || !!isLoading)} From 03709909272832627a1f3ac3d86042c9b3045259 Mon Sep 17 00:00:00 2001 From: Issa Nimaga Date: Thu, 15 Oct 2026 12:24:56 +0100 Subject: [PATCH 3/6] Address Exact Online review feedback and translations --- src/languages/de.ts | 6 +- src/languages/el.ts | 8 +- src/languages/es.ts | 2 +- src/languages/fr.ts | 2 +- src/languages/it.ts | 8 +- src/languages/ja.ts | 8 +- src/languages/nl.ts | 4 +- src/languages/pl.ts | 4 +- src/languages/pt-BR.ts | 2 +- src/languages/zh-hans.ts | 6 +- .../accounting/PolicyAccountingPage.tsx | 2 +- .../workspace/connections/ConnectionCard.tsx | 47 ++++-------- .../workspace/connections/ConnectionsGrid.tsx | 27 +++++-- .../connections/ExactOnlineWaitlistCard.tsx | 75 +++++++++++++++++++ .../connections/WorkspaceConnectionsPage.tsx | 10 ++- src/pages/workspace/connections/types.ts | 8 +- ...tsx => useAccountingConnectionListings.ts} | 7 -- .../unit/ExactOnlineConnectionListingTest.tsx | 17 ++--- 18 files changed, 150 insertions(+), 93 deletions(-) create mode 100644 src/pages/workspace/connections/ExactOnlineWaitlistCard.tsx rename src/pages/workspace/connections/{useAccountingConnectionListings.tsx => useAccountingConnectionListings.ts} (96%) diff --git a/src/languages/de.ts b/src/languages/de.ts index ec8fe818eb88..158c25d77487 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -7432,9 +7432,9 @@ Der Control-Tarif beginnt bei 9 $ pro aktivem Mitglied und Monat.`, goToODToSettings: 'Wechsle zu Expensify Classic, um deine Einstellungen zu verwalten.', exactOnline: { comingSoon: 'Demnächst verfügbar', - joinWaitlist: 'Zur Warteliste anmelden', - onWaitlist: 'Du stehst auf der Warteliste', - waitlistDescription: 'Concierge sendet dir eine Nachricht, sobald diese Integration verfügbar ist, und erklärt dir, wie du sie verbindest.', + joinWaitlist: 'Warteliste beitreten', + onWaitlist: 'Sie stehen auf der Warteliste', + waitlistDescription: 'Concierge sendet Ihnen eine Nachricht, sobald diese Integration bereit ist, und erklärt Ihnen, wie Sie die Verbindung herstellen.', }, setup: 'Verbinden', connectToSandbox: 'Mit Sandbox verbinden', diff --git a/src/languages/el.ts b/src/languages/el.ts index 9da276c4af65..006cfd8226ea 100644 --- a/src/languages/el.ts +++ b/src/languages/el.ts @@ -7561,10 +7561,10 @@ _Για πιο αναλυτικές οδηγίες, [επισκεφθείτε τ `Προέκυψε σφάλμα με μια σύνδεση που έχει ρυθμιστεί στο Expensify Classic. [Μεταβείτε στο Expensify Classic για να διορθώσετε αυτό το ζήτημα.](${oldDotPolicyConnectionsURL})`, goToODToSettings: 'Μεταβείτε στο Expensify Classic για να διαχειριστείτε τις ρυθμίσεις σας.', exactOnline: { - comingSoon: 'Σύντομα κοντά σας', - joinWaitlist: 'Εγγραφή στη λίστα αναμονής', - onWaitlist: 'Είστε στη λίστα αναμονής', - waitlistDescription: 'Το Concierge θα σας στείλει ένα μήνυμα για να σας ενημερώσει πότε αυτή η ενσωμάτωση είναι έτοιμη και πώς να συνδεθείτε.', + comingSoon: 'Έρχεται σύντομα', + joinWaitlist: 'Εγγραφείτε στη λίστα αναμονής', + onWaitlist: 'Είστε σε λίστα αναμονής', + waitlistDescription: 'Ο Concierge θα σας στείλει ένα μήνυμα για να σας ενημερώσει πότε θα είναι έτοιμη αυτή η ενσωμάτωση και πώς να τη συνδέσετε.', }, setup: 'Σύνδεση', connectToSandbox: 'Σύνδεση στο sandbox', diff --git a/src/languages/es.ts b/src/languages/es.ts index c1d205a4fd0a..ae4d5f77bea3 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -7232,7 +7232,7 @@ El plan Controlar empieza en 9 $ por miembro activo al mes.`, comingSoon: 'Próximamente', joinWaitlist: 'Unirse a la lista de espera', onWaitlist: 'Estás en la lista de espera', - waitlistDescription: 'Concierge te enviará un mensaje para avisarte cuando esta integración esté lista y cómo conectarla.', + waitlistDescription: 'Concierge te enviará un mensaje para avisarte cuando esta integración esté lista y cómo conectarte.', }, setup: 'Configurar', connectToSandbox: 'Conectar con Sandbox', diff --git a/src/languages/fr.ts b/src/languages/fr.ts index c377a2fe7246..efd6d0e7c102 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -7453,7 +7453,7 @@ Le forfait Control commence à 9 $ par Membre actif et par mois.`, comingSoon: 'Bientôt disponible', joinWaitlist: 'Rejoindre la liste d’attente', onWaitlist: 'Vous êtes sur la liste d’attente', - waitlistDescription: 'Concierge vous enverra un message pour vous informer lorsque cette intégration sera prête et vous expliquer comment la connecter.', + waitlistDescription: 'Concierge vous enverra un message pour vous informer lorsque cette intégration sera prête et comment la connecter.', }, setup: 'Connecter', connectToSandbox: 'Se connecter à Sandbox', diff --git a/src/languages/it.ts b/src/languages/it.ts index 479fedd24651..99ee7ae44ad1 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -7388,10 +7388,10 @@ Il piano Control parte da 9 $ al mese per ogni membro attivo.`, `Si è verificato un errore con una connessione configurata in Expensify Classic. [Vai a Expensify Classic per risolvere questo problema.](${oldDotPolicyConnectionsURL})`, goToODToSettings: 'Vai a Expensify Classic per gestire le tue impostazioni.', exactOnline: { - comingSoon: 'In arrivo', - joinWaitlist: 'Iscriviti alla lista d’attesa', - onWaitlist: 'Sei nella lista d’attesa', - waitlistDescription: 'Concierge ti invierà un messaggio per avvisarti quando questa integrazione sarà pronta e come collegarla.', + comingSoon: 'Presto in arrivo', + joinWaitlist: 'Unisciti alla lista d’attesa', + onWaitlist: 'Sei in lista d’attesa', + waitlistDescription: 'Concierge ti invierà un messaggio per farti sapere quando questa integrazione sarà pronta e come collegarla.', }, setup: 'Connetti', connectToSandbox: 'Connetti alla sandbox', diff --git a/src/languages/ja.ts b/src/languages/ja.ts index 6f7174b334c9..283c0f77b430 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -7297,10 +7297,10 @@ Control プランは、アクティブメンバー1人あたり月額 $9 から `Expensify Classic で設定されている接続にエラーがあります。[この問題を解決するには Expensify Classic に移動してください。](${oldDotPolicyConnectionsURL})`, goToODToSettings: '設定を管理するには、Expensify Classic に移動してください。', exactOnline: { - comingSoon: '近日公開', - joinWaitlist: 'ウェイトリストに登録', - onWaitlist: 'ウェイトリストに登録されています', - waitlistDescription: 'この連携の準備が整った際に、Conciergeからお知らせと接続方法についてのメッセージをお送りします。', + comingSoon: '近日公開予定', + joinWaitlist: 'ウェイトリストに参加', + onWaitlist: '順番待ちリストに登録されています', + waitlistDescription: 'この連携の準備が整い、接続方法が決まりましたら、Concierge からメッセージでお知らせします。', }, setup: '接続', connectToSandbox: 'Sandbox に接続', diff --git a/src/languages/nl.ts b/src/languages/nl.ts index 5cd9a29417a4..0340fb4d56cc 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -7372,9 +7372,9 @@ Het Control-abonnement begint bij $9 per actieve deelnemer per maand.`, goToODToSettings: 'Ga naar Expensify Classic om je instellingen te beheren.', exactOnline: { comingSoon: 'Binnenkort beschikbaar', - joinWaitlist: 'Aanmelden voor de wachtlijst', + joinWaitlist: 'Aanmelden voor wachtlijst', onWaitlist: 'Je staat op de wachtlijst', - waitlistDescription: 'Concierge stuurt je een bericht om je te laten weten wanneer deze integratie klaar is en hoe je verbinding maakt.', + waitlistDescription: 'Concierge stuurt je een bericht om je te laten weten wanneer deze integratie klaar is en hoe je verbinding kunt maken.', }, setup: 'Verbinden', connectToSandbox: 'Verbinden met sandbox', diff --git a/src/languages/pl.ts b/src/languages/pl.ts index 657210ff9123..7f7588cb7e93 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -7397,10 +7397,10 @@ Plan Control zaczyna się od 9 USD za aktywnego członka miesięcznie.`, `Wystąpił błąd z połączeniem skonfigurowanym w Expensify Classic. [Przejdź do Expensify Classic, aby rozwiązać ten problem.](${oldDotPolicyConnectionsURL})`, goToODToSettings: 'Przejdź do Expensify Classic, aby zarządzać swoimi ustawieniami.', exactOnline: { - comingSoon: 'Wkrótce', + comingSoon: 'Wkrótce dostępne', joinWaitlist: 'Dołącz do listy oczekujących', onWaitlist: 'Jesteś na liście oczekujących', - waitlistDescription: 'Concierge wyśle Ci wiadomość, aby poinformować Cię, kiedy ta integracja będzie gotowa i jak ją połączyć.', + waitlistDescription: 'Concierge wyśle ci wiadomość, żeby dać znać, kiedy ta integracja będzie gotowa i jak ją połączyć.', }, setup: 'Połącz', connectToSandbox: 'Połącz z sandboxem', diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index 39a402186950..f6b6af685f4a 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -7376,7 +7376,7 @@ O plano Control começa em US$ 9 por membro ativo por mês.`, comingSoon: 'Em breve', joinWaitlist: 'Entrar na lista de espera', onWaitlist: 'Você está na lista de espera', - waitlistDescription: 'O Concierge enviará uma mensagem para avisar quando esta integração estiver pronta e como conectá-la.', + waitlistDescription: 'O Concierge vai enviar uma mensagem para avisar quando essa integração estiver pronta e como conectá-la.', }, setup: 'Conectar', connectToSandbox: 'Conectar ao sandbox', diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index fcdd2e5a389e..d5e1f6419080 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -7115,9 +7115,9 @@ _如需更详细的说明,请[访问我们的帮助网站](${CONST.NETSUITE_IM goToODToSettings: '前往 Expensify Classic 管理您的设置。', exactOnline: { comingSoon: '即将推出', - joinWaitlist: '加入等候名单', - onWaitlist: '你已加入等候名单', - waitlistDescription: '此集成准备就绪后,Concierge 会发送消息通知你,并说明如何连接。', + joinWaitlist: '加入候补名单', + onWaitlist: '您在候补名单中', + waitlistDescription: '当此集成准备就绪并可连接时,Concierge 会向你发送消息通知你并说明如何连接。', }, setup: '连接', connectToSandbox: '连接到沙盒', diff --git a/src/pages/workspace/accounting/PolicyAccountingPage.tsx b/src/pages/workspace/accounting/PolicyAccountingPage.tsx index 47fb6011ded2..26dbee1e4d70 100644 --- a/src/pages/workspace/accounting/PolicyAccountingPage.tsx +++ b/src/pages/workspace/accounting/PolicyAccountingPage.tsx @@ -419,7 +419,7 @@ function PolicyAccountingPage({policy}: PolicyAccountingPageProps) { {CONST.POLICY.CONNECTIONS.NAME_USER_FRIENDLY.exactOnline} diff --git a/src/pages/workspace/connections/ConnectionCard.tsx b/src/pages/workspace/connections/ConnectionCard.tsx index 4e7dc0a9e979..af12aeef5495 100644 --- a/src/pages/workspace/connections/ConnectionCard.tsx +++ b/src/pages/workspace/connections/ConnectionCard.tsx @@ -15,8 +15,6 @@ import useStyleUtils from '@hooks/useStyleUtils'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; -import getPlatform from '@libs/getPlatform'; - import variables from '@styles/variables'; import CONST from '@src/CONST'; @@ -40,7 +38,7 @@ function ConnectionCard({listing, shouldUseFullWidth}: ConnectionCardProps) { const {translate} = useLocalize(); const {isOffline} = useNetwork(); const icons = useMemoizedLazyExpensifyIcons(['Building', 'Plus']); - const {title, icon, status, offer, onConnect, onConfigure, registerConnectButton, isLoading, rightComponent, badgeText} = listing; + const {title, icon, status, offer, onConnect, onConfigure, registerConnectButton, isLoading} = listing; const connectIcon = ( @@ -76,28 +74,6 @@ function ConnectionCard({listing, shouldUseFullWidth}: ConnectionCardProps) { ); } - let titleAddon; - if (offer) { - titleAddon = ( - - ); - } else if (badgeText) { - titleAddon = ( - - ); - } - return ( + ) : undefined + } numberOfLinesDescription={1} wrapperStyle={[styles.pv4, styles.ph4]} onPress={status ? onConfigure : onConnect} sentryLabel={CONST.SENTRY_LABEL.WORKSPACE.CONNECTIONS.CARD} - interactive={!rightComponent} - // On iOS, disable row grouping when a custom action needs its own VoiceOver focus. - shouldBeAccessible={rightComponent ? getPlatform() !== CONST.PLATFORM.IOS : undefined} - accessibilityLabel={badgeText ? `${title}, ${badgeText}` : undefined} - disabled={!rightComponent && !status && (isOffline || !!isLoading)} + disabled={!status && (isOffline || !!isLoading)} shouldShowRightComponent - rightComponent={rightComponent ?? (status ? statusComponent : connectIcon)} + rightComponent={status ? statusComponent : connectIcon} /> ); diff --git a/src/pages/workspace/connections/ConnectionsGrid.tsx b/src/pages/workspace/connections/ConnectionsGrid.tsx index d4a6750e35da..851506d3a283 100644 --- a/src/pages/workspace/connections/ConnectionsGrid.tsx +++ b/src/pages/workspace/connections/ConnectionsGrid.tsx @@ -4,21 +4,25 @@ import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useThemeStyles from '@hooks/useThemeStyles'; +import CONST from '@src/CONST'; + import React from 'react'; import {View} from 'react-native'; import type {ConnectionListing} from './types'; import ConnectionCard from './ConnectionCard'; +import ExactOnlineWaitlistCard from './ExactOnlineWaitlistCard'; // Invisible fillers take the empty slots of the last row so its cards keep their column width const FILLER_COUNT = 2; type ConnectionsGridProps = { listings: ConnectionListing[]; + policyID?: string; }; -function ConnectionsGrid({listings}: ConnectionsGridProps) { +function ConnectionsGrid({listings, policyID}: ConnectionsGridProps) { const styles = useThemeStyles(); const {shouldUseNarrowLayout, isMediumScreenWidth} = useResponsiveLayout(); // Medium screens can leave less than a card's minimum width once both navigation panes are shown @@ -26,13 +30,20 @@ function ConnectionsGrid({listings}: ConnectionsGridProps) { return ( - {listings.map((listing) => ( - - ))} + {listings.map((listing) => + listing.key === CONST.POLICY.CONNECTIONS.NAME.EXACT_ONLINE ? ( + + ) : ( + + ), + )} {!shouldStackCards && Array.from({length: FILLER_COUNT}, (_, index) => ( + + + + + + + {title} + + + + + + + + + ); +} + +export default ExactOnlineWaitlistCard; diff --git a/src/pages/workspace/connections/WorkspaceConnectionsPage.tsx b/src/pages/workspace/connections/WorkspaceConnectionsPage.tsx index 7c1dba68a69d..ea73b8b996d4 100644 --- a/src/pages/workspace/connections/WorkspaceConnectionsPage.tsx +++ b/src/pages/workspace/connections/WorkspaceConnectionsPage.tsx @@ -299,7 +299,10 @@ function WorkspaceConnectionsPage({policy}: WithPolicyConnectionsProps) { > {connectedListings.length > 0 && ( - + )} 0 ? styles.mt6 : styles.mt3, !shouldUseSingleColumn && [styles.flexRow, styles.alignItemsCenter, styles.gap5]]}> @@ -340,7 +343,10 @@ function WorkspaceConnectionsPage({policy}: WithPolicyConnectionsProps) { /> ) : ( - + )} {!shouldShowEmptyState && ( diff --git a/src/pages/workspace/connections/types.ts b/src/pages/workspace/connections/types.ts index 95b66156af2f..a2fa41dd0ef6 100644 --- a/src/pages/workspace/connections/types.ts +++ b/src/pages/workspace/connections/types.ts @@ -1,7 +1,7 @@ import type CONST from '@src/CONST'; import type IconAsset from '@src/types/utils/IconAsset'; -import type {ComponentRef, ReactNode} from 'react'; +import type {ComponentRef} from 'react'; import type {View} from 'react-native'; import type {ValueOf} from 'type-fest'; @@ -37,12 +37,6 @@ type ConnectionListing = { onConnect?: () => void; - /** A non-interactive listing can supply its own action, such as an offline-capable waitlist signup. */ - rightComponent?: ReactNode; - - /** Label beside the provider's name when the integration is not yet available. */ - badgeText?: string; - /** Shows an "Offer" badge for a partner promotion. The badge only opens the offer when `onPress` is set. */ offer?: {onPress?: () => void}; diff --git a/src/pages/workspace/connections/useAccountingConnectionListings.tsx b/src/pages/workspace/connections/useAccountingConnectionListings.ts similarity index 96% rename from src/pages/workspace/connections/useAccountingConnectionListings.tsx rename to src/pages/workspace/connections/useAccountingConnectionListings.ts index b607a82b3d10..6858feb8efa1 100644 --- a/src/pages/workspace/connections/useAccountingConnectionListings.tsx +++ b/src/pages/workspace/connections/useAccountingConnectionListings.ts @@ -16,7 +16,6 @@ import Navigation from '@libs/Navigation/Navigation'; import {getConnectedIntegration, getIntegrationLastSuccessfulDate} from '@libs/PolicyUtils'; import {useAccountingActions, useAccountingState} from '@pages/workspace/accounting/AccountingContext'; -import ExactOnlineWaitlistButton from '@pages/workspace/accounting/ExactOnlineWaitlistButton'; import {getAccountingIntegrationData, getSynchronizationErrorMessage, isIntuitEnterpriseSuiteConnection} from '@pages/workspace/accounting/utils'; import CONST from '@src/CONST'; @@ -27,8 +26,6 @@ import type {ConnectionName} from '@src/types/onyx/Policy'; import type {OnyxEntry} from 'react-native-onyx'; -import React from 'react'; - import type {ConnectionListing, ConnectionStatus} from './types'; import {getSyncStatusMessage} from './utils'; @@ -58,7 +55,6 @@ function useAccountingConnectionListings(policy: OnyxEntry): ConnectionL 'DualEntrySquare', 'CampfireSquare', 'BusinessCentralSquare', - 'ExactOnlineSquare', ]); if (!policyID) { @@ -166,9 +162,6 @@ function useAccountingConnectionListings(policy: OnyxEntry): ConnectionL key: CONST.POLICY.CONNECTIONS.NAME.EXACT_ONLINE, category: CONST.TAB.CONNECTIONS.ACCOUNTING, title: CONST.POLICY.CONNECTIONS.NAME_USER_FRIENDLY.exactOnline, - icon: accountingIcons.ExactOnlineSquare, - badgeText: translate('workspace.accounting.exactOnline.comingSoon'), - rightComponent: , }); return listings; } diff --git a/tests/unit/ExactOnlineConnectionListingTest.tsx b/tests/unit/ExactOnlineConnectionListingTest.tsx index 2678f95fab60..893b1297482e 100644 --- a/tests/unit/ExactOnlineConnectionListingTest.tsx +++ b/tests/unit/ExactOnlineConnectionListingTest.tsx @@ -4,7 +4,7 @@ import ComposeProviders from '@components/ComposeProviders'; import {LocaleContextProvider} from '@components/LocaleContextProvider'; import OnyxListItemProvider from '@components/OnyxListItemProvider'; -import ConnectionCard from '@pages/workspace/connections/ConnectionCard'; +import ConnectionsGrid from '@pages/workspace/connections/ConnectionsGrid'; import useAccountingConnectionListings from '@pages/workspace/connections/useAccountingConnectionListings'; import {joinExactOnlineWaitlist} from '@userActions/User'; @@ -66,6 +66,7 @@ describe('Exact Online in unified Connections', () => { mockEnabledBetas = enabledBetas; await Onyx.set(ONYXKEYS.NETWORK, {shouldForceOffline: true}); const policy = createRandomPolicy(1, CONST.POLICY.TYPE.CORPORATE, 'Test workspace'); + await Onyx.set(`${ONYXKEYS.COLLECTION.POLICY}${policy.id}`, policy); const {result} = renderHook(() => useAccountingConnectionListings(policy), {wrapper: TestWrapper}); await waitForBatchedUpdatesWithAct(); const listing = result.current.find((item) => item.key === CONST.POLICY.CONNECTIONS.NAME.EXACT_ONLINE); @@ -75,16 +76,10 @@ describe('Exact Online in unified Connections', () => { // When the connection cards render and the user chooses the waitlist render( - <> - - - , + , {wrapper: TestWrapper}, ); await waitForBatchedUpdatesWithAct(); From a759e9b103f6e11aa269d88035dbc976c0024902 Mon Sep 17 00:00:00 2001 From: Issa Nimaga Date: Thu, 15 Oct 2026 14:02:43 +0100 Subject: [PATCH 4/6] Correct Spanish and French Exact Online waitlist descriptions --- src/languages/es.ts | 2 +- src/languages/fr.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/languages/es.ts b/src/languages/es.ts index 0ec1246d6531..b089caad06d7 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -7242,7 +7242,7 @@ El plan Controlar empieza en 9 $ por miembro activo al mes.`, comingSoon: 'Próximamente', joinWaitlist: 'Unirse a la lista de espera', onWaitlist: 'Estás en la lista de espera', - waitlistDescription: 'Concierge te enviará un mensaje para avisarte cuando esta integración esté lista y cómo conectarte.', + waitlistDescription: 'Concierge te enviará un mensaje para avisarte cuando esta integración esté lista y cómo conectarla.', }, setup: 'Configurar', connectToSandbox: 'Conectar con Sandbox', diff --git a/src/languages/fr.ts b/src/languages/fr.ts index fb5bb542f749..d45b8a7a1244 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -7464,7 +7464,7 @@ Le forfait Control commence à 9 $ par Membre actif et par mois.`, comingSoon: 'Bientôt disponible', joinWaitlist: 'Rejoindre la liste d’attente', onWaitlist: 'Vous êtes sur la liste d’attente', - waitlistDescription: 'Concierge vous enverra un message pour vous informer lorsque cette intégration sera prête et comment la connecter.', + waitlistDescription: 'Concierge vous enverra un message pour vous informer lorsque cette intégration sera prête et vous expliquer comment la connecter.', }, setup: 'Connecter', connectToSandbox: 'Se connecter à Sandbox', From 87cf2138e8e1a249bd0c5308ccb63471ca8605d3 Mon Sep 17 00:00:00 2001 From: Issa Nimaga Date: Thu, 15 Oct 2026 17:08:33 +0100 Subject: [PATCH 5/6] Fix Exact Online waitlist accessibility and share its row --- .../accounting/ExactOnlineWaitlistRow.tsx | 73 +++++++++++++++++++ .../accounting/PolicyAccountingPage.tsx | 36 ++------- .../connections/ExactOnlineWaitlistCard.tsx | 45 ++---------- .../unit/ExactOnlineConnectionListingTest.tsx | 9 ++- .../PolicyAccountingPageAutoStartFlowTest.tsx | 11 ++- 5 files changed, 102 insertions(+), 72 deletions(-) create mode 100644 src/pages/workspace/accounting/ExactOnlineWaitlistRow.tsx diff --git a/src/pages/workspace/accounting/ExactOnlineWaitlistRow.tsx b/src/pages/workspace/accounting/ExactOnlineWaitlistRow.tsx new file mode 100644 index 000000000000..ca54664f907a --- /dev/null +++ b/src/pages/workspace/accounting/ExactOnlineWaitlistRow.tsx @@ -0,0 +1,73 @@ +/** Shared Exact Online identity and signup state for Accounting and Connections. */ +import UserAvatar from '@components/Avatar/UserAvatar'; +import Badge from '@components/Badge'; +import MenuItem from '@components/MenuItem'; + +import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; +import useLocalize from '@hooks/useLocalize'; +import useOnyx from '@hooks/useOnyx'; +import useThemeStyles from '@hooks/useThemeStyles'; + +import getPlatform from '@libs/getPlatform'; + +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {Policy} from '@src/types/onyx'; + +import type {StyleProp, ViewStyle} from 'react-native'; +import type {OnyxEntry} from 'react-native-onyx'; + +import React from 'react'; +import {View} from 'react-native'; + +import ExactOnlineWaitlistButton from './ExactOnlineWaitlistButton'; + +type ExactOnlineWaitlistRowProps = { + policy: OnyxEntry; + style?: StyleProp; + titleWrapperStyle?: StyleProp; + isBadgeCondensed?: boolean; +}; + +function ExactOnlineWaitlistRow({policy, style, titleWrapperStyle, isBadgeCondensed}: ExactOnlineWaitlistRowProps) { + const styles = useThemeStyles(); + const {translate} = useLocalize(); + const icons = useMemoizedLazyExpensifyIcons(['ExactOnlineSquare']); + const [signupTimestamp] = useOnyx(ONYXKEYS.NVP_EXACT_ONLINE_WAITLIST); + const title = CONST.POLICY.CONNECTIONS.NAME_USER_FRIENDLY.exactOnline; + const comingSoon = translate('workspace.accounting.exactOnline.comingSoon'); + const accessibilityLabel = [title, comingSoon, signupTimestamp && translate('workspace.accounting.exactOnline.onWaitlist')].filter(Boolean).join(', '); + + return ( + + + + + + + + {title} + + + + + + + + + ); +} + +export default ExactOnlineWaitlistRow; diff --git a/src/pages/workspace/accounting/PolicyAccountingPage.tsx b/src/pages/workspace/accounting/PolicyAccountingPage.tsx index 26dbee1e4d70..90df82a52eae 100644 --- a/src/pages/workspace/accounting/PolicyAccountingPage.tsx +++ b/src/pages/workspace/accounting/PolicyAccountingPage.tsx @@ -1,6 +1,4 @@ import ActivityIndicator from '@components/ActivityIndicator'; -import UserAvatar from '@components/Avatar/UserAvatar'; -import Badge from '@components/Badge'; import Button from '@components/Button'; import ButtonDisabledWhenOffline from '@components/Button/composed/ButtonDisabledWhenOffline'; import CollapsibleSection from '@components/CollapsibleSection'; @@ -49,7 +47,7 @@ import {View} from 'react-native'; import type {MenuItemData, PolicyAccountingPageProps} from './types'; import {AccountingContextProvider, useAccountingActions, useAccountingState} from './AccountingContext'; -import ExactOnlineWaitlistButton from './ExactOnlineWaitlistButton'; +import ExactOnlineWaitlistRow from './ExactOnlineWaitlistRow'; import useConnectedAccountingIntegration from './useConnectedAccountingIntegration'; type RouteParams = { @@ -77,7 +75,7 @@ function PolicyAccountingPage({policy}: PolicyAccountingPageProps) { const shouldDisconnectIntegrationBeforeConnecting = params?.shouldDisconnectIntegrationBeforeConnecting; const shouldConnectToIntuitEnterpriseSuite = params?.isIntuitEnterpriseSuite === 'true'; const policyID = policy?.id; - const icons = useMemoizedLazyExpensifyIcons(['QuestionMark', 'ExactOnlineSquare']); + const icons = useMemoizedLazyExpensifyIcons(['QuestionMark']); const { accountingIntegrations, connectedIntegration, @@ -401,33 +399,11 @@ function PolicyAccountingPage({policy}: PolicyAccountingPageProps) { /> )} - - - - - - - - {CONST.POLICY.CONNECTIONS.NAME_USER_FRIENDLY.exactOnline} - - - - - - - - + titleWrapperStyle={!shouldUseNarrowLayout && [styles.flexRow, styles.alignItemsCenter, styles.gap3]} + /> {!!account?.guideDetails?.email && !hasAccountingConnections(policy) && canWriteAccounting && ( - - - - - - - {title} - - - - - - - + isBadgeCondensed + /> ); } diff --git a/tests/unit/ExactOnlineConnectionListingTest.tsx b/tests/unit/ExactOnlineConnectionListingTest.tsx index 893b1297482e..68dd5fb45fe8 100644 --- a/tests/unit/ExactOnlineConnectionListingTest.tsx +++ b/tests/unit/ExactOnlineConnectionListingTest.tsx @@ -1,4 +1,4 @@ -import {fireEvent, render, renderHook, screen} from '@testing-library/react-native'; +import {act, fireEvent, render, renderHook, screen} from '@testing-library/react-native'; import ComposeProviders from '@components/ComposeProviders'; import {LocaleContextProvider} from '@components/LocaleContextProvider'; @@ -91,5 +91,12 @@ describe('Exact Online in unified Connections', () => { expect(joinExactOnlineWaitlist).toHaveBeenCalledTimes(1); expect(mockStartIntegrationFlow).not.toHaveBeenCalled(); expect(mockConnect).not.toHaveBeenCalled(); + expect(screen.getByLabelText('Exact Online, Coming soon')).toBeOnTheScreen(); + + await act(() => Onyx.set(ONYXKEYS.NVP_EXACT_ONLINE_WAITLIST, '2026-10-15 10:00:00')); + expect(screen.getByLabelText('Exact Online, Coming soon, You’re on the waitlist')).toBeOnTheScreen(); + + await act(() => Onyx.set(ONYXKEYS.NVP_EXACT_ONLINE_WAITLIST, null)); + expect(screen.getByLabelText('Exact Online, Coming soon')).toBeOnTheScreen(); }); }); diff --git a/tests/unit/PolicyAccountingPageAutoStartFlowTest.tsx b/tests/unit/PolicyAccountingPageAutoStartFlowTest.tsx index a54cfbe4b53b..08fcf313e23b 100644 --- a/tests/unit/PolicyAccountingPageAutoStartFlowTest.tsx +++ b/tests/unit/PolicyAccountingPageAutoStartFlowTest.tsx @@ -1,4 +1,4 @@ -import {fireEvent, render, screen, within} from '@testing-library/react-native'; +import {act, fireEvent, render, screen, within} from '@testing-library/react-native'; import ComposeProviders from '@components/ComposeProviders'; import {LocaleContextProvider} from '@components/LocaleContextProvider'; @@ -11,6 +11,7 @@ import PolicyAccountingPage from '@pages/workspace/accounting/PolicyAccountingPa import CONST from '@src/CONST'; import IntlStore from '@src/languages/IntlStore'; +import ONYXKEYS from '@src/ONYXKEYS'; import type {ConnectionName} from '@src/types/onyx/Policy'; import type Policy from '@src/types/onyx/Policy'; @@ -18,6 +19,7 @@ import type * as ReactNavigation from '@react-navigation/native'; import type {View} from 'react-native'; import React from 'react'; +import Onyx from 'react-native-onyx'; import createRandomPolicy from '../utils/collections/policies'; import waitForBatchedUpdates from '../utils/waitForBatchedUpdates'; @@ -273,5 +275,12 @@ describe('PolicyAccountingPage auto-started connect flow', () => { expect(screen.getByText('Exact Online')).toBeOnTheScreen(); expect(screen.getByRole('button', {name: 'Join waitlist'})).toBeOnTheScreen(); expect(mockStartIntegrationFlow).not.toHaveBeenCalled(); + expect(screen.getByLabelText('Exact Online, Coming soon')).toBeOnTheScreen(); + + await act(() => Onyx.set(ONYXKEYS.NVP_EXACT_ONLINE_WAITLIST, '2026-10-15 10:00:00')); + expect(screen.getByLabelText('Exact Online, Coming soon, You’re on the waitlist')).toBeOnTheScreen(); + + await act(() => Onyx.set(ONYXKEYS.NVP_EXACT_ONLINE_WAITLIST, null)); + expect(screen.getByLabelText('Exact Online, Coming soon')).toBeOnTheScreen(); }); }); From 264161151969555dae1a44622b32de01bfaa1b5e Mon Sep 17 00:00:00 2001 From: Issa Nimaga Date: Tue, 20 Oct 2026 17:43:50 +0100 Subject: [PATCH 6/6] Confirm Exact Online waitlist signup only after success --- src/libs/actions/User.ts | 22 ++++++-- .../accounting/ExactOnlineWaitlistButton.tsx | 43 ++++++++++----- .../accounting/ExactOnlineWaitlistRow.tsx | 4 +- src/types/onyx/Account.ts | 3 + tests/actions/UserTest.ts | 13 ++++- tests/unit/ExactOnlineWaitlistButtonTest.tsx | 55 ++++++++++++++----- 6 files changed, 104 insertions(+), 36 deletions(-) diff --git a/src/libs/actions/User.ts b/src/libs/actions/User.ts index 248f83fcfb80..c33541efc048 100644 --- a/src/libs/actions/User.ts +++ b/src/libs/actions/User.ts @@ -1333,7 +1333,18 @@ function setNameValuePair(name: TKey, value: SetNameValueP function joinExactOnlineWaitlist() { const signupTimestamp = DateUtils.getDBTime(); - const optimisticData: Array> = [ + const optimisticData: Array> = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: ONYXKEYS.ACCOUNT, + value: { + pendingFields: {exactOnlineWaitlist: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD}, + errorFields: {exactOnlineWaitlist: null}, + }, + }, + ]; + // The confirmation modal must not treat a queued signup as confirmed membership. + const successData: Array> = [ { onyxMethod: Onyx.METHOD.MERGE, key: ONYXKEYS.NVP_EXACT_ONLINE_WAITLIST, @@ -1342,7 +1353,7 @@ function joinExactOnlineWaitlist() { { onyxMethod: Onyx.METHOD.MERGE, key: ONYXKEYS.ACCOUNT, - value: {errorFields: {exactOnlineWaitlist: null}}, + value: {pendingFields: {exactOnlineWaitlist: null}}, }, ]; const failureData: Array> = [ @@ -1354,12 +1365,15 @@ function joinExactOnlineWaitlist() { { onyxMethod: Onyx.METHOD.MERGE, key: ONYXKEYS.ACCOUNT, - value: {errorFields: {exactOnlineWaitlist: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('common.genericErrorMessage')}}, + value: { + pendingFields: {exactOnlineWaitlist: null}, + errorFields: {exactOnlineWaitlist: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('common.genericErrorMessage')}, + }, }, ]; // The queued request keeps this timestamp when it retries, preserving the original signup time. - API.write(WRITE_COMMANDS.SET_NAME_VALUE_PAIR, {name: ONYXKEYS.NVP_EXACT_ONLINE_WAITLIST, value: signupTimestamp}, {optimisticData, failureData}); + API.write(WRITE_COMMANDS.SET_NAME_VALUE_PAIR, {name: ONYXKEYS.NVP_EXACT_ONLINE_WAITLIST, value: signupTimestamp}, {optimisticData, successData, failureData}); } function clearExactOnlineWaitlistError() { diff --git a/src/pages/workspace/accounting/ExactOnlineWaitlistButton.tsx b/src/pages/workspace/accounting/ExactOnlineWaitlistButton.tsx index 051443fdeb44..8a3eca80219d 100644 --- a/src/pages/workspace/accounting/ExactOnlineWaitlistButton.tsx +++ b/src/pages/workspace/accounting/ExactOnlineWaitlistButton.tsx @@ -16,7 +16,7 @@ import type {Policy} from '@src/types/onyx'; import type {OnyxEntry} from 'react-native-onyx'; -import React, {useRef} from 'react'; +import React, {useEffect, useRef} from 'react'; type ExactOnlineWaitlistButtonProps = { policy: OnyxEntry; @@ -28,37 +28,54 @@ function ExactOnlineWaitlistButton({policy}: ExactOnlineWaitlistButtonProps) { const {showConfirmModal} = useConfirmModal(); const {canWrite, showReadOnlyModal} = usePolicyFeatureWriteAccess(policy, CONST.POLICY.POLICY_FEATURE.ACCOUNTING); const [signupTimestamp, signupTimestampMetadata] = useOnyx(ONYXKEYS.NVP_EXACT_ONLINE_WAITLIST); + const [pendingAction, pendingActionMetadata] = useOnyx(ONYXKEYS.ACCOUNT, {selector: (account) => account?.pendingFields?.exactOnlineWaitlist}); + const [errors] = useOnyx(ONYXKEYS.ACCOUNT, {selector: (account) => account?.errorFields?.exactOnlineWaitlist}); const isJoiningWaitlist = useRef(false); - if (signupTimestamp) { + // Queued writes finish through Onyx, including retries after the press handler has returned. + useEffect(() => { + if (!isJoiningWaitlist.current || pendingAction || pendingActionMetadata.status === 'loading') { + return; + } + if (errors) { + isJoiningWaitlist.current = false; + return; + } + if (!signupTimestamp) { + return; + } + + isJoiningWaitlist.current = false; + showConfirmModal({ + title: translate('workspace.accounting.exactOnline.onWaitlist'), + prompt: translate('workspace.accounting.exactOnline.waitlistDescription'), + confirmText: translate('common.buttonConfirm'), + shouldShowCancelButton: false, + }); + }, [errors, pendingAction, pendingActionMetadata.status, showConfirmModal, signupTimestamp, translate]); + + if (signupTimestamp && !pendingAction) { return {translate('workspace.accounting.exactOnline.onWaitlist')}; } const joinWaitlist = () => { - if (isJoiningWaitlist.current) { + if (isJoiningWaitlist.current || pendingAction) { return; } if (!canWrite) { showReadOnlyModal(); return; } - // Prevent repeated presses before the optimistic account value reaches this component. + // Prevent repeated presses before the pending account state reaches this component. isJoiningWaitlist.current = true; joinExactOnlineWaitlist(); - showConfirmModal({ - title: translate('workspace.accounting.exactOnline.onWaitlist'), - prompt: translate('workspace.accounting.exactOnline.waitlistDescription'), - confirmText: translate('common.buttonConfirm'), - shouldShowCancelButton: false, - }).then(() => { - isJoiningWaitlist.current = false; - }); }; return (